User:0xDeadbeef/Deputy.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
/*!
*
* DEPUTY
*
* A copyright management and investigation assistance tool.
*
* ------------------------------------------------------------------------
*
* Copyright 2022 Chlod Aidan Alejandro
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* ------------------------------------------------------------------------
*
* NOTE TO USERS AND DEBUGGERS: This userscript is originally written in
* TypeScript. The original TypeScript code is converted to raw JavaScript
* during the build process. To view the original source code, visit
*
* https://github.com/ChlodAlejandro/deputy
*
*/
// <nowiki>
/*!
* @package idb
* @version 7.1.0
* @license ISC
* @author Jake Archibald
* @url https://github.com/jakearchibald/idb
*//*!
* @package tsx-dom
* @version 1.4.0
* @license MIT
* @author Santo Pfingsten
* @url https://github.com/Lusito/tsx-dom
*//*!
* @package broadcastchannel-polyfill
* @version 1.0.1
* @license Unlicense
* @author Joshua Bell
* @url https://github.com/JSmith01/broadcastchannel-polyfill
*//*!
* @package @chlodalejandro/parsoid
* @version 2.0.1-37ea110
* @license MIT
* @author Chlod Alejandro
* @url https://github.com/ChlodAlejandro/parsoid-document
*/
(function () {
'use strict';
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
const instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);
let idbProxyableTypes;
let cursorAdvanceMethods;
// This is a function to prevent it throwing up in node environments.
function getIdbProxyableTypes() {
return (idbProxyableTypes ||
(idbProxyableTypes = [
IDBDatabase,
IDBObjectStore,
IDBIndex,
IDBCursor,
IDBTransaction,
]));
}
// This is a function to prevent it throwing up in node environments.
function getCursorAdvanceMethods() {
return (cursorAdvanceMethods ||
(cursorAdvanceMethods = [
IDBCursor.prototype.advance,
IDBCursor.prototype.continue,
IDBCursor.prototype.continuePrimaryKey,
]));
}
const cursorRequestMap = new WeakMap();
const transactionDoneMap = new WeakMap();
const transactionStoreNamesMap = new WeakMap();
const transformCache = new WeakMap();
const reverseTransformCache = new WeakMap();
function promisifyRequest(request) {
const promise = new Promise((resolve, reject) => {
const unlisten = () => {
request.removeEventListener('success', success);
request.removeEventListener('error', error);
};
const success = () => {
resolve(wrap(request.result));
unlisten();
};
const error = () => {
reject(request.error);
unlisten();
};
request.addEventListener('success', success);
request.addEventListener('error', error);
});
promise
.then((value) => {
// Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval
// (see wrapFunction).
if (value instanceof IDBCursor) {
cursorRequestMap.set(value, request);
}
// Catching to avoid "Uncaught Promise exceptions"
})
.catch(() => { });
// This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This
// is because we create many promises from a single IDBRequest.
reverseTransformCache.set(promise, request);
return promise;
}
function cacheDonePromiseForTransaction(tx) {
// Early bail if we've already created a done promise for this transaction.
if (transactionDoneMap.has(tx))
return;
const done = new Promise((resolve, reject) => {
const unlisten = () => {
tx.removeEventListener('complete', complete);
tx.removeEventListener('error', error);
tx.removeEventListener('abort', error);
};
const complete = () => {
resolve();
unlisten();
};
const error = () => {
reject(tx.error || new DOMException('AbortError', 'AbortError'));
unlisten();
};
tx.addEventListener('complete', complete);
tx.addEventListener('error', error);
tx.addEventListener('abort', error);
});
// Cache it for later retrieval.
transactionDoneMap.set(tx, done);
}
let idbProxyTraps = {
get(target, prop, receiver) {
if (target instanceof IDBTransaction) {
// Special handling for transaction.done.
if (prop === 'done')
return transactionDoneMap.get(target);
// Polyfill for objectStoreNames because of Edge.
if (prop === 'objectStoreNames') {
return target.objectStoreNames || transactionStoreNamesMap.get(target);
}
// Make tx.store return the only store in the transaction, or undefined if there are many.
if (prop === 'store') {
return receiver.objectStoreNames[1]
? undefined
: receiver.objectStore(receiver.objectStoreNames[0]);
}
}
// Else transform whatever we get back.
return wrap(target[prop]);
},
set(target, prop, value) {
target[prop] = value;
return true;
},
has(target, prop) {
if (target instanceof IDBTransaction &&
(prop === 'done' || prop === 'store')) {
return true;
}
return prop in target;
},
};
function replaceTraps(callback) {
idbProxyTraps = callback(idbProxyTraps);
}
function wrapFunction(func) {
// Due to expected object equality (which is enforced by the caching in `wrap`), we
// only create one new func per func.
// Edge doesn't support objectStoreNames (booo), so we polyfill it here.
if (func === IDBDatabase.prototype.transaction &&
!('objectStoreNames' in IDBTransaction.prototype)) {
return function (storeNames, ...args) {
const tx = func.call(unwrap(this), storeNames, ...args);
transactionStoreNamesMap.set(tx, storeNames.sort ? storeNames.sort() : [storeNames]);
return wrap(tx);
};
}
// Cursor methods are special, as the behaviour is a little more different to standard IDB. In
// IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the
// cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense
// with real promises, so each advance methods returns a new promise for the cursor object, or
// undefined if the end of the cursor has been reached.
if (getCursorAdvanceMethods().includes(func)) {
return function (...args) {
// Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use
// the original object.
func.apply(unwrap(this), args);
return wrap(cursorRequestMap.get(this));
};
}
return function (...args) {
// Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use
// the original object.
return wrap(func.apply(unwrap(this), args));
};
}
function transformCachableValue(value) {
if (typeof value === 'function')
return wrapFunction(value);
// This doesn't return, it just creates a 'done' promise for the transaction,
// which is later returned for transaction.done (see idbObjectHandler).
if (value instanceof IDBTransaction)
cacheDonePromiseForTransaction(value);
if (instanceOfAny(value, getIdbProxyableTypes()))
return new Proxy(value, idbProxyTraps);
// Return the same value back if we're not going to transform it.
return value;
}
function wrap(value) {
// We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because
// IDB is weird and a single IDBRequest can yield many responses, so these can't be cached.
if (value instanceof IDBRequest)
return promisifyRequest(value);
// If we've already transformed this value before, reuse the transformed value.
// This is faster, but it also provides object equality.
if (transformCache.has(value))
return transformCache.get(value);
const newValue = transformCachableValue(value);
// Not all types are transformed.
// These may be primitive types, so they can't be WeakMap keys.
if (newValue !== value) {
transformCache.set(value, newValue);
reverseTransformCache.set(newValue, value);
}
return newValue;
}
const unwrap = (value) => reverseTransformCache.get(value);
/**
* Open a database.
*
* @param name Name of the database.
* @param version Schema version.
* @param callbacks Additional callbacks.
*/
function openDB(name, version, { blocked, upgrade, blocking, terminated } = {}) {
const request = indexedDB.open(name, version);
const openPromise = wrap(request);
if (upgrade) {
request.addEventListener('upgradeneeded', (event) => {
upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event);
});
}
if (blocked) {
request.addEventListener('blocked', (event) => blocked(
// Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405
event.oldVersion, event.newVersion, event));
}
openPromise
.then((db) => {
if (terminated)
db.addEventListener('close', () => terminated());
if (blocking) {
db.addEventListener('versionchange', (event) => blocking(event.oldVersion, event.newVersion, event));
}
})
.catch(() => { });
return openPromise;
}
const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count'];
const writeMethods = ['put', 'add', 'delete', 'clear'];
const cachedMethods = new Map();
function getMethod(target, prop) {
if (!(target instanceof IDBDatabase &&
!(prop in target) &&
typeof prop === 'string')) {
return;
}
if (cachedMethods.get(prop))
return cachedMethods.get(prop);
const targetFuncName = prop.replace(/FromIndex$/, '');
const useIndex = prop !== targetFuncName;
const isWrite = writeMethods.includes(targetFuncName);
if (
// Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge.
!(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) ||
!(isWrite || readMethods.includes(targetFuncName))) {
return;
}
const method = async function (storeName, ...args) {
// isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :(
const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly');
let target = tx.store;
if (useIndex)
target = target.index(args.shift());
// Must reject if op rejects.
// If it's a write operation, must reject if tx.done rejects.
// Must reject with op rejection first.
// Must resolve with op value.
// Must handle both promises (no unhandled rejections)
return (await Promise.all([
target[targetFuncName](...args),
isWrite && tx.done,
]))[0];
};
cachedMethods.set(prop, method);
return method;
}
replaceTraps((oldTraps) => ({
...oldTraps,
get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver),
has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop),
}));
var version = "0.8.0";
var gitAbbrevHash = "5523baa";
var gitBranch = "main";
var gitDate = "Mon, 8 Jul 2024 12:36:45 +0800";
var gitVersion = "0.8.0+g5523baa";
/**
*
*/
class MwApi {
/**
* @return A mw.Api for the current wiki.
*/
static get action() {
var _a;
return (_a = this._action) !== null && _a !== void 0 ? _a : (this._action = new mw.Api({
ajax: {
headers: {
'Api-User-Agent': `Deputy/${version} (https://w.wiki/7NWR; User:Chlod; wiki@chlod.net)`
}
},
parameters: {
format: 'json',
formatversion: 2,
utf8: true,
errorformat: 'html',
errorlang: mw.config.get('wgUserLanguage'),
errorsuselocal: true
}
}));
}
/**
* @return A mw.Rest for the current wiki.
*/
static get rest() {
var _a;
return (_a = this._rest) !== null && _a !== void 0 ? _a : (this._rest = new mw.Rest());
}
}
MwApi.USER_AGENT = `Deputy/${version} (https://w.wiki/7NWR; User:Chlod; wiki@chlod.net)`;
/**
* Log to the console.
*
* @param {...any} data
*/
function log(...data) {
console.log('[Deputy]', ...data);
}
/**
* Handles all browser-stored data for Deputy.
*/
class DeputyStorage {
/**
* Initialize the Deputy IndexedDB database.
*
* @return {void} A promise that resolves when a database connection is established.
*/
init() {
return __awaiter(this, void 0, void 0, function* () {
this.db = yield openDB('us-deputy', 1, {
upgrade(db, oldVersion, newVersion) {
let currentVersion = oldVersion;
const upgrader = {
0: () => {
db.createObjectStore('keyval', {
keyPath: 'key'
});
db.createObjectStore('casePageCache', {
keyPath: 'pageID'
});
db.createObjectStore('diffCache', {
keyPath: 'revid'
});
db.createObjectStore('diffStatus', {
keyPath: 'hash'
});
db.createObjectStore('pageStatus', {
keyPath: 'hash'
});
db.createObjectStore('tagCache', {
keyPath: 'key'
});
}
};
while (currentVersion < newVersion) {
upgrader[`${currentVersion}`]();
log(`Upgraded database from ${currentVersion} to ${currentVersion + 1}`);
currentVersion++;
}
}
});
yield this.getTags();
});
}
/**
* Get a value in the `keyval` store.
*
* @param key The key to get
*/
getKV(key) {
return __awaiter(this, void 0, void 0, function* () {
return window.deputy.storage.db.get('keyval', key)
.then((keyPair) => keyPair === null || keyPair === void 0 ? void 0 : keyPair.value);
});
}
/**
* Set a value in the `keyval` store.
*
* @param key The key to set
* @param value The value to set
*/
setKV(key, value) {
return __awaiter(this, void 0, void 0, function* () {
return window.deputy.storage.db.put('keyval', {
key: key,
value: value
}).then(() => true);
});
}
/**
* Get all MediaWiki tags and store them in the `tagCache` store.
*/
getTags() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
this.tagCache = {};
const tagCache = yield window.deputy.storage.db.getAll('tagCache');
if (tagCache.length === 0 ||
// 7 days
Date.now() - ((_a = yield this.getKV('tagCacheAge')) !== null && _a !== void 0 ? _a : 0) > 6048e5) {
yield MwApi.action.getMessages(['*'], {
amenableparser: true,
amincludelocal: true,
amprefix: 'tag-'
}).then((messages) => {
for (const key in messages) {
this.tagCache[key] = messages[key];
mw.messages.set(key, messages[key]);
this.db.put('tagCache', { key, value: messages[key] });
}
this.setKV('tagCacheAge', Date.now());
});
}
else {
for (const { key, value } of tagCache) {
this.tagCache[key] = value;
mw.messages.set(key, value);
}
}
});
}
}
(function(global) {
var channels = [];
function BroadcastChannel(channel) {
var $this = this;
channel = String(channel);
var id = '$BroadcastChannel$' + channel + '$';
channels[id] = channels[id] || [];
channels[id].push(this);
this._name = channel;
this._id = id;
this._closed = false;
this._mc = new MessageChannel();
this._mc.port1.start();
this._mc.port2.start();
global.addEventListener('storage', function(e) {
if (e.storageArea !== global.localStorage) return;
if (e.newValue == null || e.newValue === '') return;
if (e.key.substring(0, id.length) !== id) return;
var data = JSON.parse(e.newValue);
$this._mc.port2.postMessage(data);
});
}
BroadcastChannel.prototype = {
// BroadcastChannel API
get name() {
return this._name;
},
postMessage: function(message) {
var $this = this;
if (this._closed) {
var e = new Error();
e.name = 'InvalidStateError';
throw e;
}
var value = JSON.stringify(message);
// Broadcast to other contexts via storage events...
var key = this._id + String(Date.now()) + '$' + String(Math.random());
global.localStorage.setItem(key, value);
setTimeout(function() {
global.localStorage.removeItem(key);
}, 500);
// Broadcast to current context via ports
channels[this._id].forEach(function(bc) {
if (bc === $this) return;
bc._mc.port2.postMessage(JSON.parse(value));
});
},
close: function() {
if (this._closed) return;
this._closed = true;
this._mc.port1.close();
this._mc.port2.close();
var index = channels[this._id].indexOf(this);
channels[this._id].splice(index, 1);
},
// EventTarget API
get onmessage() {
return this._mc.port1.onmessage;
},
set onmessage(value) {
this._mc.port1.onmessage = value;
},
addEventListener: function(/*type, listener , useCapture*/) {
return this._mc.port1.addEventListener.apply(this._mc.port1, arguments);
},
removeEventListener: function(/*type, listener , useCapture*/) {
return this._mc.port1.removeEventListener.apply(this._mc.port1, arguments);
},
dispatchEvent: function(/*event*/) {
return this._mc.port1.dispatchEvent.apply(this._mc.port1, arguments);
},
};
global.BroadcastChannel = global.BroadcastChannel || BroadcastChannel;
})(self);
/**
* Generates an ID using the current time and a random number. Quick and
* dirty way to generate random IDs.
*
* @return A string in the format `TIMESTAMP++RANDOM_NUMBER`
*/
function generateId () {
return `${Date.now()}++${Math.random().toString().slice(2)}`;
}
/**
* A constant map of specific one-way Deputy message types and their respective
* response messages.
*/
const OneWayDeputyMessageMap = {
sessionRequest: 'sessionResponse',
sessionResponse: 'sessionRequest',
sessionStop: 'acknowledge',
pageStatusRequest: 'pageStatusResponse',
pageStatusResponse: 'pageStatusRequest',
pageStatusUpdate: 'acknowledge',
revisionStatusUpdate: 'acknowledge',
pageNextRevisionRequest: 'pageNextRevisionResponse',
pageNextRevisionResponse: 'pageNextRevisionRequest',
userConfigUpdate: 'userConfigUpdate',
wikiConfigUpdate: 'wikiConfigUpdate'
};
// TODO: debug
const start = Date.now();
/**
* Handles inter-tab communication and automatically broadcasts events
* to listeners.
*/
class DeputyCommunications extends EventTarget {
/**
* Initialize communications. Begins listening for messages from other tabs.
*/
init() {
// Polyfills are loaded for BroadcastChannel support on older browsers.
// eslint-disable-next-line compat/compat
this.broadcastChannel = new BroadcastChannel('deputy-itc');
this.broadcastChannel.addEventListener('message', (event) => {
// TODO: debug
log(Date.now() - start, 'comms in: ', event.data);
if (event.data && typeof event.data === 'object' && event.data._deputy) {
this.dispatchEvent(Object.assign(new Event(event.data.type), {
data: event.data
}));
}
});
}
/**
* Sends data through this broadcast channel.
*
* @param data
* @return The sent message object
*/
send(data) {
const messageId = generateId();
const message = Object.assign(data, { _deputy: true, _deputyMessageId: messageId });
this.broadcastChannel.postMessage(message);
// TODO: debug
log(Date.now() - start, 'comms out:', data);
return message;
}
/**
*
* @param original
* @param reply
*/
reply(original, reply) {
this.send(Object.assign(reply, {
_deputyRespondsTo: original._deputyMessageId
}));
}
/**
* Sends a message and waits for the first response. Subsequent responses are
* ignored. Returns `null` once the timeout has passed with no responses.
*
* @param data
* @param timeout Time to wait for a response, 500ms by default
*/
sendAndWait(data, timeout = 500) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((res) => {
const message = this.send(data);
const handlers = {};
const clearHandlers = () => {
if (handlers.listener) {
this.broadcastChannel.removeEventListener('message', handlers.listener);
}
if (handlers.timeout) {
clearTimeout(handlers.timeout);
}
};
handlers.listener = ((event) => {
log(event);
if (event.data._deputyRespondsTo === message._deputyMessageId &&
event.data.type === OneWayDeputyMessageMap[data.type]) {
res(event.data);
clearHandlers();
}
});
handlers.timeout = setTimeout(() => {
res(null);
clearHandlers();
}, timeout);
this.broadcastChannel.addEventListener('message', handlers.listener);
});
});
}
/**
* @param type The type of message to send.
* @param callback The callback to call when the message is received.
* @param options Optional options for the event listener.
* @see {@link EventTarget#addEventListener}
*/
addEventListener(type, callback, options) {
super.addEventListener(type, callback, options);
}
}
/**
* Normalizes the title into an mw.Title object based on either a given title or
* the current page.
*
* @param title The title to normalize. Default is current page.
* @return {mw.Title} A mw.Title object. `null` if not a valid title.
* @private
*/
function normalizeTitle(title) {
if (title instanceof mw.Title) {
return title;
}
else if (typeof title === 'string') {
return new mw.Title(title);
}
else if (title == null) {
// Null check goes first to avoid accessing properties of `null`.
return new mw.Title(mw.config.get('wgPageName'));
}
else if (title.title != null && title.namespace != null) {
return new mw.Title(title.title, title.namespace);
}
else {
return null;
}
}
/**
* Get the content of a page on-wiki.
*
* @param page The page to get
* @param extraOptions Extra options to pass to the request
* @param api The API object to use
* @return A promise resolving to the page content. Resolves to `null` if missing page.
*/
function getPageContent (page, extraOptions = {}, api = MwApi.action) {
return api.get(Object.assign(Object.assign(Object.assign({ action: 'query', prop: 'revisions' }, (typeof page === 'number' ? {
pageids: page
} : {
titles: normalizeTitle(page).getPrefixedText()
})), { rvprop: 'ids|content', rvslots: 'main', rvlimit: '1' }), extraOptions)).then((data) => {
const fallbackText = extraOptions.fallbacktext;
if (data.query.pages[0].revisions == null) {
if (fallbackText) {
return Object.assign(fallbackText, {
page: data.query.pages[0]
});
}
else {
return null;
}
}
return Object.assign(data.query.pages[0].revisions[0].slots.main.content, {
contentFormat: data.query.pages[0].revisions[0].slots.main.contentformat,
revid: data.query.pages[0].revisions[0].revid,
page: data.query.pages[0]
});
});
}
/**
* Used by DeputyCasePage to access the page's raw wikitext, make changes,
* etc.
*/
class DeputyCasePageWikitext {
/**
*
* @param casePage
*/
constructor(casePage) {
this.casePage = casePage;
}
/**
* Gets the wikitext for this page.
*/
getWikitext() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
return (_a = this.content) !== null && _a !== void 0 ? _a : (this.content = yield getPageContent(this.casePage.pageId));
});
}
/**
* Removes the cached wikitext for this page.
*/
resetCachedWikitext() {
this.content = undefined;
}
/**
* Gets the wikitext for a specific section. The section will be parsed using the
* wikitext cache if a section title was provided. Otherwise, it will attempt to
* grab the section using API:Query for an up-to-date version.
*
* @param section The section to edit
* @param n If the section heading appears multiple times in the page and n is
* provided, this function extracts the nth occurrence of that section heading.
*/
getSectionWikitext(section, n = 1) {
return __awaiter(this, void 0, void 0, function* () {
if (typeof section === 'number') {
return getPageContent(this.casePage.pageId, { rvsection: section }).then((v) => {
return Object.assign(v.toString(), {
revid: v.revid
});
});
}
else {
const wikitext = yield this.getWikitext();
const wikitextLines = wikitext.split('\n');
let capturing = false;
let captureLevel = 0;
let currentN = 1;
const sectionLines = [];
for (let i = 0; i < wikitextLines.length; i++) {
const line = wikitextLines[i];
const headerCheck = /^(=={1,5})\s*(.+?)\s*=={1,5}$/.exec(line);
if (!capturing &&
headerCheck != null &&
headerCheck[2] === section) {
if (currentN < n) {
currentN++;
}
else {
sectionLines.push(line);
capturing = true;
captureLevel = headerCheck[1].length;
}
}
else if (capturing) {
if (headerCheck != null && headerCheck[1].length <= captureLevel) {
capturing = false;
break;
}
else {
sectionLines.push(line);
}
}
}
return Object.assign(sectionLines.join('\n'), {
revid: wikitext.revid
});
}
});
}
}
/**
* Gets the page title of a given page ID.
*
* @param pageID
*/
function getPageTitle (pageID) {
var _a, _b, _c, _d;
return __awaiter(this, void 0, void 0, function* () {
const pageIdQuery = yield MwApi.action.get({
action: 'query',
pageids: pageID
});
const title = (_d = (_c = (_b = (_a = pageIdQuery === null || pageIdQuery === void 0 ? void 0 : pageIdQuery.query) === null || _a === void 0 ? void 0 : _a.pages) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.title) !== null && _d !== void 0 ? _d : null;
return title == null ? null : normalizeTitle(title);
});
}
/**
* Base class for Deputy cases. Extended into {@link DeputyCasePage} to refer to an
* active case page. Used to represent case pages in a more serializable way.
*/
class DeputyCase {
/**
* @param pageId The page ID of the case page.
* @param title The title of the case page.
*/
constructor(pageId, title) {
this.pageId = pageId;
this.title = title;
}
/**
* @return the title of the case page
*/
static get rootPage() {
return window.deputy.wikiConfig.cci.rootPage.get();
}
/**
* Checks if the current page (or a supplied page) is a case page (subpage of
* the root page).
*
* @param title The title of the page to check.
* @return `true` if the page is a case page.
*/
static isCasePage(title) {
return normalizeTitle(title).getPrefixedDb()
.startsWith(this.rootPage.getPrefixedDb() + '/');
}
/**
* Gets the case name by parsing the title.
*
* @param title The title of the case page
* @return The case name, or `null` if the title was not a valid case page
*/
static getCaseName(title) {
const _title = normalizeTitle(title);
if (!this.isCasePage(_title)) {
return null;
}
else {
return _title.getPrefixedText().replace(this.rootPage.getPrefixedText() + '/', '');
}
}
/**
* Creats a Deputy case object.
*
* @param pageId The page ID of the case page.
* @param title The title of the case page.
*/
static build(pageId, title) {
return __awaiter(this, void 0, void 0, function* () {
if (title == null) {
title = yield getPageTitle(pageId);
}
return new DeputyCase(pageId, title);
});
}
/**
* Gets the case name by parsing the title.
*
* @return The case name, or `null` if the title was not a valid case page
*/
getCaseName() {
return DeputyCase.getCaseName(this.title);
}
}
/**
* Returns the last item of an array.
*
* @param array The array to get the last element from
* @return The last element of the array
*/
function last(array) {
return array[array.length - 1];
}
/**
* Each WikiHeadingType implies specific fields in {@link WikiHeading}:
*
* - `PARSOID` implies that there is no headline element, and that the `h`
* element is the root heading element. This means `h.innerText` will be
* "Section title".
* - `OLD` implies that there is a headline element and possibly an editsection
* element, and that the `h` is the root heading element. This means that
* `h.innerText` will be "Section title[edit | edit source]" or similar.
* - `NEW` implies that there is a headline element and possibly an editsection
* element, and that a `div` is the root heading element. This means that
* `h.innerText` will be "Section title".
*/
var WikiHeadingType;
(function (WikiHeadingType) {
WikiHeadingType[WikiHeadingType["PARSOID"] = 0] = "PARSOID";
WikiHeadingType[WikiHeadingType["OLD"] = 1] = "OLD";
WikiHeadingType[WikiHeadingType["NEW"] = 2] = "NEW";
})(WikiHeadingType || (WikiHeadingType = {}));
/**
* Get relevant information from an H* element in a section heading.
*
* @param headingElement The heading element
* @return An object containing the relevant {@link WikiHeading} fields.
*/
function getHeadingElementInfo(headingElement) {
return {
h: headingElement,
id: headingElement.id,
title: headingElement.innerText,
level: +last(headingElement.tagName)
};
}
/**
* Annoyingly, there are many different ways that a heading can be parsed
* into depending on the version and the parser used for given wikitext.
*
* In order to properly perform such wiki heading checks, we need to identify
* if a given element is part of a wiki heading, and perform a normalization
* if so.
*
* Since this function needs to check many things before deciding if a given
* HTML element is part of a section heading or not, this also acts as an
* `isWikiHeading` check.
*
* The layout for a heading differs depending on the MediaWiki version:
*
* <b>On 1.43+ (Parser)</b>
* ```html
* <div class="mw-heading mw-heading2">
* <h2 id="Parsed_wikitext...">Parsed <i>wikitext</i>...</h2>
* <span class="mw-editsection>...</span>
* </div>
* ```
*
* <b>On Parsoid</b>
* ```html
* <h2 id="Parsed_wikitext...">Parsed <i>wikitext</i>...</h2>
* ```
*
* <b>On pre-1.43</b>
* ```html
* <h2>
* <span class="mw-headline" id="Parsed_wikitext...">Parsed <i>wikitext</i>...</span>
* <span class="mw-editsection">...</span>
* </h2>
* ```
*
* <b>Worst case execution time</b> would be if this was run with an element which was
* outside a heading and deeply nested within the page.
*
* Backwards-compatibility support may be removed in the future. This function does not
* support Parsoid specification versions lower than 2.0.
*
* @param node The node to check for
* @param ceiling An element which `node` must be in to be a valid heading.
* This is set to the `.mw-parser-output` element by default.
* @return The root heading element (can be an <h2> or <div>),
* or `null` if it is not a valid heading.
*/
function normalizeWikiHeading(node, ceiling) {
var _a;
if (node == null) {
// Not valid input, obviously.
return null;
}
const rootNode = node.getRootNode();
// Break out of text nodes until we hit an element node.
while (node.nodeType !== node.ELEMENT_NODE) {
node = node.parentNode;
if (node === rootNode) {
// We've gone too far and hit the root. This is not a wiki heading.
return null;
}
}
// node is now surely an element.
let elementNode = node;
// If this node is the 1.43+ heading root, return it immediately.
if (elementNode.classList.contains('mw-heading')) {
return Object.assign({ type: WikiHeadingType.NEW, root: elementNode }, getHeadingElementInfo(Array.from(elementNode.children)
.find(v => /^H[123456]$/.test(v.tagName))));
}
// Otherwise, we're either inside or outside a mw-heading.
// To determine if we are inside or outside, we keep climbing up until
// we either hit an <hN> or a given stop point.
// The default stop point differs on Parsoid and standard parser:
// - On Parsoid, `<body>` will be `.mw-body-content.mw-parser-output`.
// - On standard parser, we want `div.mw-body-content > div.mw-parser.output`.
// If such an element doesn't
// exist in this document, we just stop at the root element.
ceiling = (_a = ceiling !== null && ceiling !== void 0 ? ceiling : elementNode.ownerDocument.querySelector('.mw-body-content > .mw-parser-output, .mw-body-content.mw-parser-output')) !== null && _a !== void 0 ? _a : elementNode.ownerDocument.documentElement;
// While we haven't hit a heading, keep going up.
while (elementNode !== ceiling) {
if (/^H[123456]$/.test(elementNode.tagName)) {
// This element is a heading!
// Now determine if this is a MediaWiki heading.
if (elementNode.parentElement.classList.contains('mw-heading')) {
// This element's parent is a `div.mw-heading`!
return Object.assign({ type: WikiHeadingType.NEW, root: elementNode.parentElement }, getHeadingElementInfo(elementNode));
}
else {
const headline = elementNode.querySelector(':scope > .mw-headline');
if (headline != null) {
// This element has a `.mw-headline` child!
return {
type: WikiHeadingType.OLD,
root: elementNode,
h: elementNode,
id: headline.id,
title: headline.innerText,
level: +last(elementNode.tagName)
};
}
else if (elementNode.parentElement.tagName === 'SECTION' &&
elementNode.parentElement.firstElementChild === elementNode) {
// A <section> element is directly above this element, and it is the
// first element of that section!
// This is a specific format followed by the 2.8.0 MediaWiki Parsoid spec.
// https://www.mediawiki.org/wiki/Specs/HTML/2.8.0#Headings_and_Sections
return {
type: WikiHeadingType.PARSOID,
root: elementNode,
h: elementNode,
id: elementNode.id,
title: elementNode.innerText,
level: +last(elementNode.tagName)
};
}
else {
// This is a heading, but we can't figure out how it works.
// This usually means something inserted an <h2> into the DOM, and we
// accidentally picked it up.
// In that case, discard it.
return null;
}
}
}
else if (elementNode.classList.contains('mw-heading')) {
// This element is the `div.mw-heading`!
// This usually happens when we selected an element from inside the
// `span.mw-editsection` span.
return Object.assign({ type: WikiHeadingType.NEW, root: elementNode }, getHeadingElementInfo(Array.from(elementNode.children)
.find(v => /^H[123456]$/.test(v.tagName))));
}
else {
// Haven't reached the top part of a heading yet, or we are not
// in a heading. Keep climbing up the tree until we hit the ceiling.
elementNode = elementNode.parentElement;
}
}
// We hit the ceiling. This is not a wiki heading.
return null;
}
/**
* Check if a given parameter is a wikitext heading parsed into HTML.
*
* Alias for `normalizeWikiHeading( el ) != null`.
*
* @param el The element to check
* @return `true` if the element is a heading, `false` otherwise
*/
function isWikiHeading(el) {
return normalizeWikiHeading(el) != null;
}
/**
* Finds section elements from a given section heading (and optionally a predicate)
*
* @param sectionHeading
* @param sectionHeadingPredicate A function which returns `true` if the section should stop here
* @return Section headings.
*/
function getSectionElements(sectionHeading, sectionHeadingPredicate = isWikiHeading) {
const sectionMembers = [];
let nextSibling = sectionHeading.nextElementSibling;
while (nextSibling != null && !sectionHeadingPredicate(nextSibling)) {
sectionMembers.push(nextSibling);
nextSibling = nextSibling.nextElementSibling;
}
return sectionMembers;
}
/**
* Handles Deputy case pages, controls UI features, among other things.
* This class should be able to operate both on the standard MediaWiki
* parser output and the Parsoid output.
*/
class DeputyCasePage extends DeputyCase {
/**
* @param pageId The page ID of the case page.
* @param title The title of the page being accessed
* @param document The document to be used as a reference.
* @param parsoid Whether this is a Parsoid document or not.
* @param lastActive
* @param lastActiveSessions
*/
constructor(pageId, title, document, parsoid, lastActive, lastActiveSessions) {
super(pageId !== null && pageId !== void 0 ? pageId : window.deputy.currentPageId, title !== null && title !== void 0 ? title : window.deputy.currentPage);
/**
* A timestamp of when this case page was last worked on.
*/
this.lastActive = Date.now();
/**
* The sections last worked on for this case page.
*/
this.lastActiveSections = [];
this.document = document !== null && document !== void 0 ? document : window.document;
this.parsoid = parsoid !== null && parsoid !== void 0 ? parsoid : /mw: http:\/\/mediawiki.org\/rdf\//.test(this.document.documentElement.getAttribute('prefix'));
this.wikitext = new DeputyCasePageWikitext(this);
this.lastActive = lastActive !== null && lastActive !== void 0 ? lastActive : Date.now();
this.lastActiveSections = lastActiveSessions !== null && lastActiveSessions !== void 0 ? lastActiveSessions : [];
}
/**
* @param pageId The page ID of the case page.
* @param title The title of the page being accessed
* @param document The document to be used as a reference.
* @param parsoid Whether this is a Parsoid document or not.
*/
static build(pageId, title, document, parsoid) {
return __awaiter(this, void 0, void 0, function* () {
const cachedInfo = yield window.deputy.storage.db.get('casePageCache', pageId !== null && pageId !== void 0 ? pageId : window.deputy.currentPageId);
if (cachedInfo != null) {
if (pageId != null) {
// Title might be out of date. Recheck for safety.
title = yield getPageTitle(pageId);
}
// Fix for old data (moved from section name to IDs as of c5251642)
const oldSections = cachedInfo.lastActiveSections.some((v) => v.indexOf(' ') !== -1);
if (oldSections) {
cachedInfo.lastActiveSections =
cachedInfo.lastActiveSections.map((v) => v.replace(/ /g, '_'));
}
const casePage = new DeputyCasePage(pageId, title, document, parsoid, cachedInfo.lastActive, cachedInfo.lastActiveSections);
if (oldSections) {
// Save to fix the data in storage
yield casePage.saveToCache();
}
return casePage;
}
else {
return new DeputyCasePage(pageId, title, document, parsoid);
}
});
}
/**
* Checks if a given element is a valid contribution survey heading.
*
* @param el The element to check for
* @return `true` if the given heading is a valid contribution survey heading.
*/
isContributionSurveyHeading(el) {
if (!(el instanceof HTMLElement)) {
return false;
}
const heading = normalizeWikiHeading(el);
return heading != null &&
// Require that this heading is already normalized.
// TODO: Remove at some point.
// This shouldn't be required if double-normalization wasn't a thing.
el === heading.h &&
// eslint-disable-next-line security/detect-non-literal-regexp
new RegExp(window.deputy.wikiConfig.cci.headingMatch.get()).test(heading.title);
}
/**
* Finds the first contribution survey heading. This is always an <h*> element
* with the content matching the pattern "Pages \d+ to \d+"
*
* @return The <h*> element of the heading.
*/
findFirstContributionSurveyHeadingElement() {
return this.findContributionSurveyHeadings()[0];
}
/**
* Find a contribution survey heading by section name.
*
* @param sectionIdentifier The section identifier to look for, usually the section
* name unless `useId` is set to true.
* @param useId Whether to use the section name instead of the ID
* @return The <h*> element of the heading.
*/
findContributionSurveyHeading(sectionIdentifier, useId = false) {
return this.findContributionSurveyHeadings()
.find((v) => normalizeWikiHeading(v)[useId ? 'id' : 'title'] === sectionIdentifier);
}
/**
* Finds all contribution survey headings. These are <h*> elements
* with the content matching the pattern "Pages \d+ to \d+"
*
* @return The <h*> element of the heading.
*/
findContributionSurveyHeadings() {
if (!DeputyCasePage.isCasePage()) {
throw new Error('Current page is not a case page. Expected subpage of ' +
DeputyCasePage.rootPage.getPrefixedText());
}
else {
return Array.from(this.document.querySelectorAll(
// All headings (`h1, h2, h3, h4, h5, h6`)
[1, 2, 3, 4, 5, 6]
.map((i) => `.mw-parser-output h${i}`)
.join(',')))
.filter((h) => this.isContributionSurveyHeading(h));
}
}
/**
* Gets all elements that are part of a contribution survey "section", that is
* a set of elements including the section heading and all elements succeeding
* the heading until (and exclusive of) the heading of the next section.
*
* In other words,
* YES: === Pages 1 to 2 ===
* YES: * [[Page 1]]
* YES: * [[Page 2]]
* YES:
* NO : === Pages 3 to 4 ===
*
* @param sectionHeading The section heading to work with
* @return An array of all HTMLElements covered by the section
*/
getContributionSurveySection(sectionHeading) {
const heading = normalizeWikiHeading(sectionHeading);
const ceiling = heading.root.parentElement;
return getSectionElements(heading.root, (el) => {
var _a, _b;
// TODO: Avoid double normalization
const norm = normalizeWikiHeading(el, ceiling);
return (heading.level >= ((_a = norm === null || norm === void 0 ? void 0 : norm.level) !== null && _a !== void 0 ? _a : Infinity)) ||
this.isContributionSurveyHeading((_b = norm === null || norm === void 0 ? void 0 : norm.h) !== null && _b !== void 0 ? _b : el);
});
}
/**
* Check if this page is cached.
*/
isCached() {
return __awaiter(this, void 0, void 0, function* () {
return (yield window.deputy.storage.db.get('casePageCache', this.pageId)) != null;
});
}
/**
* Saves the current page to the IDB page cache.
*/
saveToCache() {
return __awaiter(this, void 0, void 0, function* () {
yield window.deputy.storage.db.put('casePageCache', {
pageID: this.pageId,
lastActive: this.lastActive,
lastActiveSections: this.lastActiveSections
});
});
}
/**
* Deletes the current page from the cache. This is generally not advised, unless the
* user wishes to forget the case page entirely.
*/
deleteFromCache() {
return __awaiter(this, void 0, void 0, function* () {
yield window.deputy.storage.db.delete('casePageCache', this.pageId);
});
}
/**
* Bumps this page's last active timestamp.
*/
bumpActive() {
return __awaiter(this, void 0, void 0, function* () {
this.lastActive = Date.now();
yield this.saveToCache();
});
}
/**
* Add a section to the list of active sessions. This is used for automatic starting
* and for one-click continuation of past active sessions.
*
* @param sectionId The ID of the section to add.
*/
addActiveSection(sectionId) {
return __awaiter(this, void 0, void 0, function* () {
const lastActiveSection = this.lastActiveSections.indexOf(sectionId);
if (lastActiveSection === -1) {
this.lastActiveSections.push(sectionId);
yield this.saveToCache();
}
});
}
/**
* Remove a section from the list of active sections. This will disable autostart
* for this section.
*
* @param sectionId ID of the section to remove
*/
removeActiveSection(sectionId) {
return __awaiter(this, void 0, void 0, function* () {
const lastActiveSection = this.lastActiveSections.indexOf(sectionId);
if (lastActiveSection !== -1) {
this.lastActiveSections.splice(lastActiveSection, 1);
yield this.saveToCache();
}
});
}
}
var dist = {};
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* License: MIT
* @author Santo Pfingsten
* @see https://github.com/Lusito/tsx-dom
*/
Object.defineProperty(dist, "__esModule", { value: true });
var h_1 = dist.h = void 0;
function applyChild(element, child) {
if (child instanceof Element)
element.appendChild(child);
else if (typeof child === "string" || typeof child === "number")
element.appendChild(document.createTextNode(child.toString()));
else
console.warn("Unknown type to append: ", child);
}
function applyChildren(element, children) {
for (const child of children) {
if (!child && child !== 0)
continue;
if (Array.isArray(child))
applyChildren(element, child);
else
applyChild(element, child);
}
}
function transferKnownProperties(source, target) {
for (const key of Object.keys(source)) {
if (key in target)
target[key] = source[key];
}
}
function createElement(tag, attrs) {
const options = (attrs === null || attrs === void 0 ? void 0 : attrs.is) ? { is: attrs.is } : undefined;
if (attrs === null || attrs === void 0 ? void 0 : attrs.xmlns)
return document.createElementNS(attrs.xmlns, tag, options);
return document.createElement(tag, options);
}
function h(tag, attrs, ...children) {
if (typeof tag === "function")
return tag(Object.assign(Object.assign({}, attrs), { children }));
const element = createElement(tag, attrs);
if (attrs) {
for (const name of Object.keys(attrs)) {
// Ignore some debug props that might be added by bundlers
if (name === "__source" || name === "__self" || name === "is" || name === "xmlns")
continue;
const value = attrs[name];
if (name.startsWith("on")) {
const finalName = name.replace(/Capture$/, "");
const useCapture = name !== finalName;
const eventName = finalName.toLowerCase().substring(2);
element.addEventListener(eventName, value, useCapture);
}
else if (name === "style" && typeof value !== "string") {
// Special handler for style with a value of type CSSStyleDeclaration
transferKnownProperties(value, element.style);
}
else if (name === "dangerouslySetInnerHTML")
element.innerHTML = value;
else if (value === true)
element.setAttribute(name, name);
else if (value || value === 0)
element.setAttribute(name, value.toString());
}
}
applyChildren(element, children);
return element;
}
h_1 = dist.h = h;
/**
* The CCI session start link. Starts a CCI session when pressed.
*
* @param heading The heading to use as a basis
* @param casePage If a DeputyCasePage is provided, a "continue" button will be shown instead.
* @return The link element to be displayed
*/
function DeputyCCISessionStartLink (heading, casePage) {
return h_1("span", { class: "deputy dp-sessionStarter" },
h_1("span", { class: "dp-sessionStarter-bracket" }, "["),
h_1("a", { onClick: () => __awaiter(this, void 0, void 0, function* () {
if (casePage && casePage.lastActiveSections.length > 0) {
const headingId = heading.id;
if (window.deputy.config.cci.openOldOnContinue.get()) {
if (casePage.lastActiveSections.indexOf(headingId) === -1) {
yield casePage.addActiveSection(headingId);
}
yield window.deputy.session.DeputyRootSession.continueSession(casePage);
}
else {
yield window.deputy.session.DeputyRootSession.continueSession(casePage, [headingId]);
}
}
else {
yield window.deputy.session.DeputyRootSession.startSession(heading.h);
}
}) }, mw.message(casePage && casePage.lastActiveSections.length > 0 ?
'deputy.session.continue' :
'deputy.session.start').text()),
h_1("span", { class: "dp-sessionStarter-bracket" }, "]"));
}
/**
* Removes an element from its document.
*
* @param element
* @return The removed element
*/
function removeElement (element) {
var _a;
return (_a = element === null || element === void 0 ? void 0 : element.parentElement) === null || _a === void 0 ? void 0 : _a.removeChild(element);
}
/**
* Log errors to the console.
*
* @param {...any} data
*/
function error(...data) {
console.error('[Deputy]', ...data);
}
/**
* Unwraps an OOUI widget from its JQuery `$element` variable and returns it as an
* HTML element.
*
* @param el The widget to unwrap.
* @return The unwrapped widget.
*/
function unwrapWidget (el) {
if (el.$element == null) {
error(el);
throw new Error('Element is not an OOUI Element!');
}
return el.$element[0];
}
/**
* Creates the "Start working on section" overlay over existing contribution survey
* sections that are not upgraded.
*
* @param props
* @param props.casePage
* @param props.heading
* @param props.height
* @return HTML element
*/
function DeputyCCISessionAddSection (props) {
const { casePage, heading } = props;
const startButton = new OO.ui.ButtonWidget({
classes: ['dp-cs-section-addButton'],
icon: 'play',
label: mw.msg('deputy.session.add'),
flags: ['primary', 'progressive']
});
const element = h_1("div", { style: { height: props.height + 'px' }, class: "dp-cs-section-add" }, unwrapWidget(startButton));
startButton.on('click', () => {
// This element is automatically appended to the UL of the section, which is a no-no
// for ContributionSurveySection. This sneakily removes this element before any sort
// of activation is performed.
removeElement(element);
window.deputy.session.rootSession.activateSection(casePage, heading);
});
return element;
}
/**
* Clones a regular expression.
*
* @param regex The regular expression to clone.
* @param options
* @return A new regular expression object.
*/
function cloneRegex$1 (regex, options = {}) {
return new RegExp(options.transformer ? options.transformer(regex.source) :
`${options.pre || ''}${regex.source}${options.post || ''}`, regex.flags);
}
/**
* Contains information about a specific revision in a ContributionSurveyRow.
*/
class ContributionSurveyRevision {
/**
* Creates a new ContributionSurveyRowRevision
*
* @param row
* @param revisionData
*/
constructor(row, revisionData) {
Object.assign(this, revisionData);
this.row = row;
}
}
/**
* Data that constructs a raw contribution survey row.
*/
/**
* Parser for {@link ContributionSurveyRow}s.
*
* This is used directly in unit tests. Do not import unnecessary
* dependencies, as they may indirectly import the entire Deputy
* codebase outside a browser environment.
*/
class ContributionSurveyRowParser {
/**
*
* @param wikitext
*/
constructor(wikitext) {
this.wikitext = wikitext;
this.current = wikitext;
}
/**
* Parses a wikitext contribution survey row into a {@link RawContributionSurveyRow}.
* If invalid, an Error is thrown with relevant information.
*
* @return Components of a parsed contribution survey row.
*/
parse() {
var _a, _b;
this.current = this.wikitext;
const bullet = this.eatUntil(/^[^*\s]/g);
if (!bullet) {
throw new Error('dp-malformed-row');
}
const creation = this.eatExpression(/^\s*'''N'''\s*/g) != null;
const page = this.eatExpression(/\[\[([^\]|]+)(?:\|.*)?]]/g, 1);
if (!page) {
// Malformed or unparsable listing.
throw new Error('dp-undetectable-page-name');
}
let extras =
// [[Special:Diff/12345|6789]]
(_a = this.eatUntil(/^(?:'''?)?\[\[Special:Diff\/\d+/, true)) !== null && _a !== void 0 ? _a :
// {{dif|12345|6789}}
this.eatUntil(/^(?:'''?)?{{dif\|\d+/, true);
let diffsBolded = false;
// At this point, `extras` is either a string or `null`. If it's a string,
// extras exist, and we should add them. If not, there's likely no more
// revisions to be processed here, and can assume that the rest is user comments.
const revids = [];
const revidText = {};
let diffs = null, comments, diffTemplate = '[[Special:Diff/$1|($2)]]';
if (extras) {
const starting = this.current;
let diff = true;
while (diff) {
const diffMatch =
// [[Special:Diff/12345|6789]]
(_b = this.eatExpressionMatch(/\s*(?:'''?)?\[\[Special:Diff\/(\d+)(?:\|([^\]]*))?]](?:'''?)?/g)) !== null && _b !== void 0 ? _b :
// {{dif|12345|6789}}
this.eatExpressionMatch(/\s*(?:'''?)?{{dif\|(\d+)\|([^}]+)}}(?:'''?)?/g);
diff = diffMatch === null || diffMatch === void 0 ? void 0 : diffMatch[1];
if (diff != null) {
revids.push(+diff);
revidText[+diff] = diffMatch[2].replace(/^\(|\)$/g, '');
}
}
// All diff links removed. Get diff of starting and current to get entire diff part.
diffs = starting.slice(0, starting.length - this.current.length);
// Bolded diffs support.
if (diffs.slice(0, 3) === "'''" &&
diffs.slice(-3) === "'''" &&
!diffs.slice(3, -3).includes("'''")) {
diffsBolded = true;
}
// Pre-2014 style support.
if ((diffs !== null && diffs !== void 0 ? diffs : '').includes('{{dif')) {
diffsBolded = true;
diffTemplate = '{{dif|$1|($2)}}';
}
// Comments should be empty, but just in case we do come across comments.
comments = this.isEmpty() ? null : this.eatRemaining();
}
else {
// Try to grab extras. This is done by detecting any form of parentheses and
// matching them, including any possible included colon. If that doesn't work,
// try pulling out just the colon.
const maybeExtras = this.eatExpression(/\s*(?::\s*)?\(.+?\)(?:\s*:)?\s*/) || this.eatExpression(/\s*:\s*/g);
if (maybeExtras) {
extras = maybeExtras;
}
// Only comments probably remain. Eat out whitespaces and the rest is a comment.
extras = (extras || '') + (this.eatUntil(/^\S/g, true) || '');
if (extras === '') {
extras = null;
}
comments = this.getCurrentLength() > 0 ? this.eatRemaining() : null;
}
// "{bullet}{creation}[[{page}]]{extras}{diffs}{comments}"
return {
type: (extras || comments || diffs) == null ? 'pageonly' : 'detailed',
bullet,
creation,
page,
extras,
diffs,
comments,
revids,
revidText,
diffTemplate,
diffsTemplate: diffsBolded ? "'''$1'''" : '$1'
};
}
/**
* Returns `true` if the working string is empty.
*
* @return `true` if the length of `current` is zero. `false` if otherwise.
*/
isEmpty() {
return this.current.length === 0;
}
/**
* @return the length of the working string.
*/
getCurrentLength() {
return this.current.length;
}
/**
* Views the next character to {@link ContributionSurveyRowParser#eat}.
*
* @return The first character of the working string.
*/
peek() {
return this.current[0];
}
/**
* Pops the first character off the working string and returns it.
*
* @return First character of the working string, pre-mutation.
*/
eat() {
const first = this.current[0];
this.current = this.current.slice(1);
return first;
}
/**
* Continue eating from the string until a string or regular expression
* is matched. Unlike {@link eatExpression}, passed regular expressions
* will not be re-wrapped with `^(?:)`. These must be added on your own if
* you wish to match the start of the string.
*
* @param pattern The string or regular expression to match.
* @param noFinish If set to `true`, `null` will be returned instead if the
* pattern is never matched. The working string will be reset to its original
* state if this occurs. This prevents the function from being too greedy.
* @return The consumed characters.
*/
eatUntil(pattern, noFinish) {
const starting = this.current;
let consumed = '';
while (this.current.length > 0) {
if (typeof pattern === 'string') {
if (this.current.startsWith(pattern)) {
return consumed;
}
}
else {
if (cloneRegex$1(pattern).test(this.current)) {
return consumed;
}
}
consumed += this.eat();
}
if (noFinish && this.current.length === 0) {
// We finished the string! Reset.
this.current = starting;
return null;
}
else {
return consumed;
}
}
/**
* Eats a given expression from the start of the working string. If the working
* string does not contain the given expression, `null` is returned (and not a
* blank string). Only eats once, so any expression must be greedy if different
* behavior is expected.
*
* The regular expression passed into this function is automatically re-wrapped
* with `^(?:<source>)`. Avoid adding these expressions on your own.
*
* @param pattern The pattern to match.
* @param n The capture group to return (returns the entire string (`0`) by default)
* @return The consumed characters.
*/
eatExpression(pattern, n = 0) {
const expression = new RegExp(`^(?:${pattern.source})`,
// Ban global and multiline, useless since this only matches once and to
// ensure that the reading remains 'flat'.
pattern.flags.replace(/[gm]/g, ''));
const match = expression.exec(this.current);
if (match) {
this.current = this.current.slice(match[0].length);
return match[n];
}
else {
return null;
}
}
/**
* Eats a given expression from the start of the working string. If the working
* string does not contain the given expression, `null` is returned (and not a
* blank string). Only eats once, so any expression must be greedy if different
* behavior is expected.
*
* The regular expression passed into this function is automatically re-wrapped
* with `^(?:<source>)`. Avoid adding these expressions on your own.
*
* @param pattern The pattern to match.
* @return A {@link RegExpExecArray}.
*/
eatExpressionMatch(pattern) {
const expression = new RegExp(`^(?:${pattern.source})`,
// Ban global and multiline, useless since this only matches once and to
// ensure that the reading remains 'flat'.
pattern.flags.replace(/[gm]/g, ''));
const match = expression.exec(this.current);
if (match) {
this.current = this.current.slice(match[0].length);
return match;
}
else {
return null;
}
}
/**
* Consumes the rest of the working string.
*
* @return The remaining characters in the working string.
*/
eatRemaining() {
const remaining = this.current;
this.current = '';
return remaining;
}
}
var ContributionSurveyRowSort;
(function (ContributionSurveyRowSort) {
// Chronological
ContributionSurveyRowSort[ContributionSurveyRowSort["Date"] = 0] = "Date";
// Reverse chronological
ContributionSurveyRowSort[ContributionSurveyRowSort["DateReverse"] = 1] = "DateReverse";
// New size - old size
ContributionSurveyRowSort[ContributionSurveyRowSort["Bytes"] = 2] = "Bytes";
})(ContributionSurveyRowSort || (ContributionSurveyRowSort = {}));
/**
* Sleep for an specified amount of time.
*
* @param ms Milliseconds to sleep for.
*/
function sleep(ms) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((res) => {
setTimeout(res, ms);
});
});
}
/**
* Handles requests that might get hit by a rate limit. Wraps around
* `fetch` and ensures that all users of the Requester only request
* a single time per 100 ms on top of the time it takes to load
* previous requests. Also runs on four "threads", allowing at
* least a certain level of asynchronicity.
*
* Particularly used when a multitude of requests have a chance to
* DoS a service.
*/
class Requester {
/**
* Processes things in the fetchQueue.
*/
static processFetch() {
return __awaiter(this, void 0, void 0, function* () {
if (Requester.fetchActive >= Requester.maxThreads) {
return;
}
Requester.fetchActive++;
const next = Requester.fetchQueue.shift();
if (next) {
const data =
// eslint-disable-next-line prefer-spread
yield fetch.apply(null, next[1])
.then((res) => {
// Return false for survivable cases. In this case, we'll re-queue
// the request.
if (res.status === 429 || res.status === 502) {
return res.status;
}
else {
return res;
}
}, next[0][1]);
if (data instanceof Response) {
next[0][0](data);
}
else if (typeof data === 'number') {
Requester.fetchQueue.push(next);
}
}
yield sleep(Requester.minTime);
Requester.fetchActive--;
setTimeout(Requester.processFetch, 0);
});
}
}
/**
* Maximum number of requests to be processed simultaneously.
*/
Requester.maxThreads = 4;
/**
* Minimum amount of milliseconds to wait between each request.
*/
Requester.minTime = 100;
/**
* Requests to be performed. Takes tuples containing a resolve-reject pair and arguments
* to be passed into the fetch function.
*/
Requester.fetchQueue = [];
/**
* Number of requests currently being processed. Must be lower than
* {@link maxThreads}.
*/
Requester.fetchActive = 0;
Requester.fetch = (...args) => {
let res, rej;
const fakePromise = new Promise((_res, _rej) => {
res = _res;
rej = _rej;
});
Requester.fetchQueue.push([[res, rej], args]);
setTimeout(Requester.processFetch, 0);
return fakePromise;
};
/**
* Transforms the `redirects` object returned by MediaWiki's `query` action into an
* object instead of an array.
*
* @param redirects
* @param normalized
* @return Redirects as an object
*/
function toRedirectsObject(redirects, normalized) {
var _a;
if (redirects == null) {
return {};
}
const out = {};
for (const redirect of redirects) {
out[redirect.from] = redirect.to;
}
// Single-level redirect-normalize loop check
for (const normal of normalized) {
out[normal.from] = (_a = out[normal.to]) !== null && _a !== void 0 ? _a : normal.to;
}
return out;
}
/**
* A configuration. Defines settings and setting groups.
*/
class ConfigurationBase {
// eslint-disable-next-line jsdoc/require-returns-check
/**
* @return the configuration from the current wiki.
*/
static load() {
throw new Error('Unimplemented method.');
}
/**
* Creates a new Configuration.
*/
constructor() { }
/**
* Deserializes a JSON configuration into this configuration. This WILL overwrite
* past settings.
*
* @param serializedData
*/
deserialize(serializedData) {
var _a;
for (const group in this.all) {
const groupObject = this.all[group];
for (const key in this.all[group]) {
const setting = groupObject[key];
if (((_a = serializedData === null || serializedData === void 0 ? void 0 : serializedData[group]) === null || _a === void 0 ? void 0 : _a[key]) !== undefined) {
setting.set(setting.deserialize ?
// Type-checked upon declaration, just trust it to skip errors.
setting.deserialize(serializedData[group][key]) :
serializedData[group][key]);
}
}
}
}
/**
* @return the serialized version of the configuration. All `undefined` values are stripped
* from output. If a category remains unchanged from defaults, it is skipped. If the entire
* configuration remains unchanged, `null` is returned.
*/
serialize() {
const config = {};
for (const group of Object.keys(this.all)) {
const groupConfig = {};
const groupObject = this.all[group];
for (const key in this.all[group]) {
const setting = groupObject[key];
if (setting.get() === setting.defaultValue && !setting.alwaysSave) {
continue;
}
const serialized = setting.serialize ?
// Type-checked upon declaration, just trust it to skip errors.
setting.serialize(setting.get()) : setting.get();
if (serialized !== undefined) {
groupConfig[key] = serialized;
}
}
if (Object.keys(groupConfig).length > 0) {
config[group] = groupConfig;
}
}
if (Object.keys(config).length > 0) {
return config;
}
else {
return null;
}
}
}
/**
* Works like `Object.values`.
*
* @param obj The object to get the values of.
* @return The values of the given object as an array
*/
function getObjectValues(obj) {
return Object.keys(obj).map((key) => obj[key]);
}
/**
* Log warnings to the console.
*
* @param {...any} data
*/
function warn(...data) {
console.warn('[Deputy]', ...data);
}
/**
* Refers to a specific setting on the configuration. Should be initialized with
* a raw (serialized) type and an actual (deserialized) type.
*
* This is used for both client and wiki-wide configuration.
*/
class Setting {
/**
*
* @param options
* @param options.serialize Serialization function. See {@link Setting#serialize}
* @param options.deserialize Deserialization function. See {@link Setting#deserialize}
* @param options.alwaysSave See {@link Setting#alwaysSave}.
* @param options.defaultValue Default value. If not supplied, `undefined` is used.
* @param options.displayOptions See {@link Setting#displayOptions}
* @param options.allowedValues See {@link Setting#allowedValues}
*/
constructor(options) {
var _a, _b;
this.serialize = options.serialize;
this.deserialize = options.deserialize;
this.displayOptions = options.displayOptions;
this.allowedValues = options.allowedValues;
this.value = this.defaultValue = options.defaultValue;
this.alwaysSave = options.alwaysSave;
this.isDisabled = ((_a = options.displayOptions) === null || _a === void 0 ? void 0 : _a.disabled) != null ?
(typeof options.displayOptions.disabled === 'function' ?
options.displayOptions.disabled.bind(this) :
() => options.displayOptions.disabled) : () => false;
this.isHidden = ((_b = options.displayOptions) === null || _b === void 0 ? void 0 : _b.hidden) != null ?
(typeof options.displayOptions.hidden === 'function' ?
options.displayOptions.hidden.bind(this) :
() => options.displayOptions.hidden) : () => false;
}
/**
* @return `true` if `this.value` is not null or undefined.
*/
ok() {
return this.value != null;
}
/**
* @return The current value of this setting.
*/
get() {
return this.value;
}
/**
* Sets the value and performs validation. If the input is an invalid value, and
* `throwOnInvalid` is false, the value will be reset to default.
*
* @param v
* @param throwOnInvalid
*/
set(v, throwOnInvalid = false) {
if (this.locked) {
warn('Attempted to modify locked setting.');
return;
}
if (this.allowedValues) {
const keys = Array.isArray(this.allowedValues) ?
this.allowedValues : getObjectValues(this.allowedValues);
if (Array.isArray(v)) {
if (v.some((v1) => keys.indexOf(v1) === -1)) {
if (throwOnInvalid) {
throw new Error('Invalid value');
}
v = this.value;
}
}
else {
if (this.allowedValues && keys.indexOf(v) === -1) {
if (throwOnInvalid) {
throw new Error('Invalid value');
}
v = this.value;
}
}
}
this.value = v;
}
/**
* Resets this setting to its original value.
*/
reset() {
this.set(this.defaultValue);
}
/**
* Parses a given raw value and mutates the setting.
*
* @param raw The raw value to parse.
* @return The new value.
*/
load(raw) {
return (this.value = this.deserialize(raw));
}
/**
* Prevents the value of the setting from being changed. Used for debugging.
*/
lock() {
this.locked = true;
}
/**
* Allows the value of the setting to be changed. Used for debugging.
*/
unlock() {
this.locked = false;
}
}
Setting.basicSerializers = {
serialize: (value) => value,
deserialize: (value) => value
};
/**
* Checks if two MediaWiki page titles are equal.
*
* @param title1
* @param title2
* @return `true` if `title1` and `title2` refer to the same page
*/
function equalTitle(title1, title2) {
return normalizeTitle(title1).getPrefixedDb() === normalizeTitle(title2).getPrefixedDb();
}
var deputySettingsStyles = ".deputy-setting {margin-bottom: 1em;}.deputy-setting > .oo-ui-fieldLayout-align-top .oo-ui-fieldLayout-header .oo-ui-labelElement-label {font-weight: bold;}.dp-mb {margin-bottom: 1em;}.deputy-about {display: flex;}.deputy-about > :first-child {flex: 0;}.deputy-about > :first-child > img {height: 5em;width: auto;}.ltr .deputy-about > :first-child {margin-right: 1em;}.rtl .deputy-about > :first-child {margin-left: 1em;}.deputy-about > :nth-child(2) {flex: 1;}.deputy-about > :nth-child(2) > :first-child > * {display: inline;}.deputy-about > :nth-child(2) > :first-child > :first-child {font-weight: bold;font-size: 2em;}.deputy-about > :nth-child(2) > :first-child > :nth-child(2) {color: gray;vertical-align: bottom;margin-left: 0.4em;}.deputy-about > :nth-child(2) > :not(:first-child) {margin-top: 0.5em;}.ltr .deputy-about + div > :not(:last-child) {margin-right: 0.5em;}.rtl .deputy-about + div > :not(:last-child) {margin-left: 0.5em;}.ltr .deputy-about + div {text-align: right;}.rtl .deputy-about + div {text-align: left;}";
/**
* Works like `Object.fromEntries`
*
* @param obj The object to get the values of.
* @return The values of the given object as an array
*/
function fromObjectEntries(obj) {
const i = {};
for (const [key, value] of obj) {
i[key] = value;
}
return i;
}
/**
* Generates configuration properties for serialized <b>string</b> enums.
*
* Trying to use anything that isn't a string enum here (union enum, numeral enum)
* will likely cause serialization/deserialization failures.
*
* @param _enum
* @param defaultValue
* @return Setting properties.
*/
function generateEnumConfigurationProperties(_enum, defaultValue) {
return {
serialize: (value) => value === defaultValue ? undefined : value,
deserialize: (value) => value,
displayOptions: {
type: 'radio'
},
allowedValues: fromObjectEntries(Array.from(new Set(Object.keys(_enum)).values())
.map((v) => [_enum[v], _enum[v]])),
defaultValue: defaultValue
};
}
var PortletNameView;
(function (PortletNameView) {
PortletNameView["Full"] = "full";
PortletNameView["Short"] = "short";
PortletNameView["Acronym"] = "acronym";
})(PortletNameView || (PortletNameView = {}));
var CompletionAction;
(function (CompletionAction) {
CompletionAction["Nothing"] = "nothing";
CompletionAction["Reload"] = "reload";
})(CompletionAction || (CompletionAction = {}));
var TripleCompletionAction;
(function (TripleCompletionAction) {
TripleCompletionAction["Nothing"] = "nothing";
TripleCompletionAction["Reload"] = "reload";
TripleCompletionAction["Redirect"] = "redirect";
})(TripleCompletionAction || (TripleCompletionAction = {}));
var ContributionSurveyRowSigningBehavior;
(function (ContributionSurveyRowSigningBehavior) {
ContributionSurveyRowSigningBehavior["Always"] = "always";
ContributionSurveyRowSigningBehavior["AlwaysTrace"] = "alwaysTrace";
ContributionSurveyRowSigningBehavior["AlwaysTraceLastOnly"] = "alwaysTraceLastOnly";
ContributionSurveyRowSigningBehavior["LastOnly"] = "lastOnly";
ContributionSurveyRowSigningBehavior["Never"] = "never";
})(ContributionSurveyRowSigningBehavior || (ContributionSurveyRowSigningBehavior = {}));
/**
* A configuration. Defines settings and setting groups.
*/
class UserConfiguration extends ConfigurationBase {
/**
* Creates a new Configuration.
*
* @param serializedData
*/
constructor(serializedData = {}) {
var _a;
super();
this.core = {
/**
* Numerical code that identifies this config version. Increments for every breaking
* configuration file change.
*/
configVersion: new Setting({
defaultValue: UserConfiguration.configVersion,
displayOptions: { hidden: true },
alwaysSave: true
}),
language: new Setting({
defaultValue: mw.config.get('wgUserLanguage'),
displayOptions: { type: 'select' }
}),
modules: new Setting({
defaultValue: ['cci', 'ante', 'ia'],
displayOptions: { type: 'checkboxes' },
allowedValues: ['cci', 'ante', 'ia']
}),
portletNames: new Setting(generateEnumConfigurationProperties(PortletNameView, PortletNameView.Full)),
seenAnnouncements: new Setting({
defaultValue: [],
displayOptions: { hidden: true }
}),
dangerMode: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox'
}
})
};
this.cci = {
enablePageToolbar: new Setting({
defaultValue: true,
displayOptions: {
type: 'checkbox'
}
}),
showCvLink: new Setting({
defaultValue: true,
displayOptions: {
type: 'checkbox'
}
}),
showUsername: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox'
}
}),
autoCollapseRows: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox'
}
}),
autoShowDiff: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox'
}
}),
maxRevisionsToAutoShowDiff: new Setting({
defaultValue: 2,
displayOptions: {
type: 'number',
// Force any due to self-reference
disabled: (config) => !config.cci.autoShowDiff.get(),
extraOptions: {
min: 1
}
}
}),
maxSizeToAutoShowDiff: new Setting({
defaultValue: 500,
displayOptions: {
type: 'number',
// Force any due to self-reference
disabled: (config) => !config.cci.autoShowDiff.get(),
extraOptions: {
min: -1
}
}
}),
forceUtc: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox'
}
}),
signingBehavior: new Setting(generateEnumConfigurationProperties(ContributionSurveyRowSigningBehavior, ContributionSurveyRowSigningBehavior.Always)),
signSectionArchive: new Setting({
defaultValue: true,
displayOptions: {
type: 'checkbox'
}
}),
openOldOnContinue: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox'
}
})
};
this.ante = {
enableAutoMerge: new Setting({
defaultValue: false,
displayOptions: {
type: 'checkbox',
disabled: 'unimplemented'
}
}),
onSubmit: new Setting(generateEnumConfigurationProperties(CompletionAction, CompletionAction.Reload))
};
this.ia = {
responses: new Setting(Object.assign(Object.assign({}, Setting.basicSerializers), { defaultValue: null, displayOptions: {
disabled: 'unimplemented',
type: 'unimplemented'
} })),
enablePageToolbar: new Setting({
defaultValue: true,
displayOptions: {
type: 'checkbox',
disabled: 'unimplemented'
}
}),
defaultEntirePage: new Setting({
defaultValue: true,
displayOptions: {
type: 'checkbox'
}
}),
defaultFromUrls: new Setting({
defaultValue: true,
displayOptions: {
type: 'checkbox'
}
}),
onHide: new Setting(generateEnumConfigurationProperties(TripleCompletionAction, TripleCompletionAction.Reload)),
onSubmit: new Setting(generateEnumConfigurationProperties(TripleCompletionAction, TripleCompletionAction.Reload)),
onBatchSubmit: new Setting(generateEnumConfigurationProperties(CompletionAction, CompletionAction.Reload))
};
this.type = 'user';
this.all = { core: this.core, cci: this.cci, ante: this.ante, ia: this.ia };
if (serializedData) {
this.deserialize(serializedData);
}
if (mw.storage.get(`mw-${UserConfiguration.optionKey}-lastVersion`) !== version) ;
mw.storage.set(`mw-${UserConfiguration.optionKey}-lastVersion`, version);
if ((_a = window.deputy) === null || _a === void 0 ? void 0 : _a.comms) {
window.deputy.comms.addEventListener('userConfigUpdate', (e) => {
// Update the configuration based on another tab's message.
this.deserialize(e.data.config);
});
}
}
/**
* @return the configuration from the current wiki.
*/
static load() {
const config = new UserConfiguration();
try {
if (mw.user.options.get(UserConfiguration.optionKey)) {
const decodedOptions = JSON.parse(mw.user.options.get(UserConfiguration.optionKey));
config.deserialize(decodedOptions);
}
}
catch (e) {
error(e, mw.user.options.get(UserConfiguration.optionKey));
mw.hook('deputy.i18nDone').add(function notifyConfigFailure() {
mw.notify(mw.msg('deputy.loadError.userConfig'), {
type: 'error'
});
mw.hook('deputy.i18nDone').remove(notifyConfigFailure);
});
config.save();
}
return config;
}
/**
* Saves the configuration.
*/
save() {
return __awaiter(this, void 0, void 0, function* () {
yield MwApi.action.saveOption(UserConfiguration.optionKey, JSON.stringify(this.serialize()));
});
}
}
UserConfiguration.configVersion = 1;
UserConfiguration.optionKey = 'userjs-deputy';
/* eslint-disable mediawiki/msg-doc */
let InternalConfigurationGroupTabPanel$1;
/**
* Initializes the process element.
*/
function initConfigurationGroupTabPanel$1() {
InternalConfigurationGroupTabPanel$1 = class ConfigurationGroupTabPanel extends OO.ui.TabPanelLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
super(`configurationGroupPage_${config.group}`);
this.config = config;
this.mode = config.config instanceof UserConfiguration ? 'user' : 'wiki';
if (this.mode === 'wiki') {
this.$element.append(new OO.ui.MessageWidget({
classes: [
'deputy', 'dp-mb'
],
type: 'warning',
label: mw.msg('deputy.settings.dialog.wikiConfigWarning')
}).$element);
}
for (const settingKey of Object.keys(this.settings)) {
const setting = this.settings[settingKey];
if (setting.isHidden(this.config.config)) {
continue;
}
switch (setting.displayOptions.type) {
case 'checkbox':
this.$element.append(this.newCheckboxField(settingKey, setting));
break;
case 'checkboxes':
this.$element.append(this.newCheckboxesField(settingKey, setting));
break;
case 'radio':
this.$element.append(this.newRadioField(settingKey, setting));
break;
case 'text':
this.$element.append(this.newStringField(settingKey, setting, setting.displayOptions.extraOptions));
break;
case 'number':
this.$element.append(this.newNumberField(settingKey, setting, setting.displayOptions.extraOptions));
break;
case 'page':
this.$element.append(this.newPageField(settingKey, setting, setting.displayOptions.extraOptions));
break;
case 'code':
this.$element.append(this.newCodeField(settingKey, setting, setting.displayOptions.extraOptions));
break;
default:
this.$element.append(this.newUnimplementedField(settingKey));
break;
}
}
}
/**
* @return The {@Link Setting}s for this group.
*/
get settings() {
return this.config.config.all[this.config.group];
}
/**
* Sets up the tab item
*/
setupTabItem() {
this.tabItem.setLabel(this.getMsg(this.config.group));
return this;
}
/**
* @return the i18n message for this setting tab.
*
* @param messageKey
*/
getMsg(messageKey) {
return mw.msg(`deputy.setting.${this.mode}.${messageKey}`);
}
/**
* Gets the i18n message for a given setting.
*
* @param settingKey
* @param key
* @return A localized string
*/
getSettingMsg(settingKey, key) {
return this.getMsg(`${this.config.group}.${settingKey}.${key}`);
}
/**
* @param settingKey
* @param allowedValues
* @return a tuple array of allowed values that can be used in OOUI `items` parameters.
*/
getAllowedValuesArray(settingKey, allowedValues) {
const items = [];
if (Array.isArray(allowedValues)) {
for (const key of allowedValues) {
const message = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.${key}`);
items.push([key, message.exists() ? message.text() : key]);
}
}
else {
for (const key of Object.keys(allowedValues)) {
const message = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.${key}`);
items.push([key, message.exists() ? message.text() : key]);
}
}
return items;
}
/**
* Creates an unimplemented setting notice.
*
* @param settingKey
* @return An HTMLElement of the given setting's field.
*/
newUnimplementedField(settingKey) {
const desc = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.description`);
return h_1("div", { class: "deputy-setting" },
h_1("b", null, this.getSettingMsg(settingKey, 'name')),
desc.exists() ? h_1("p", { style: { fontSize: '0.925em', color: '#54595d' } }, desc.text()) : '',
h_1("p", null, mw.msg('deputy.settings.dialog.unimplemented')));
}
/**
* Creates a checkbox field.
*
* @param settingKey
* @param setting
* @return An HTMLElement of the given setting's field.
*/
newCheckboxField(settingKey, setting) {
const isDisabled = setting.isDisabled(this.config.config);
const desc = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.description`);
const field = new OO.ui.CheckboxInputWidget({
selected: setting.get(),
disabled: isDisabled !== undefined && isDisabled !== false
});
const layout = new OO.ui.FieldLayout(field, {
align: 'inline',
label: this.getSettingMsg(settingKey, 'name'),
help: typeof isDisabled === 'string' ?
this.getSettingMsg(settingKey, isDisabled) :
desc.exists() ? desc.text() : undefined,
helpInline: true
});
field.on('change', () => {
setting.set(field.isSelected());
this.emit('change');
});
// Attach disabled re-checker
this.on('change', () => {
field.setDisabled(!!setting.isDisabled(this.config.config));
});
return h_1("div", { class: "deputy-setting" }, unwrapWidget(layout));
}
/**
* Creates a new checkbox set field.
*
* @param settingKey
* @param setting
* @return An HTMLElement of the given setting's field.
*/
newCheckboxesField(settingKey, setting) {
const isDisabled = setting.isDisabled(this.config.config);
const desc = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.description`);
const field = new OO.ui.CheckboxMultiselectInputWidget({
value: setting.get(),
disabled: isDisabled !== undefined && isDisabled !== false,
options: this.getAllowedValuesArray(settingKey, setting.allowedValues)
.map(([key, label]) => ({ data: key, label }))
});
const layout = new OO.ui.FieldLayout(field, {
align: 'top',
label: this.getSettingMsg(settingKey, 'name'),
help: typeof isDisabled === 'string' ?
this.getSettingMsg(settingKey, isDisabled) :
desc.exists() ? desc.text() : undefined,
helpInline: true
});
// TODO: @types/oojs-ui limitation
field.on('change', (items) => {
const finalData = Array.isArray(setting.allowedValues) ?
items :
field.getValue().map((v) => setting.allowedValues[v]);
setting.set(finalData);
this.emit('change');
});
// Attach disabled re-checker
this.on('change', () => {
field.setDisabled(!!setting.isDisabled(this.config.config));
});
return h_1("div", { class: "deputy-setting" }, unwrapWidget(layout));
}
/**
* Creates a new radio set field.
*
* @param settingKey
* @param setting
* @return An HTMLElement of the given setting's field.
*/
newRadioField(settingKey, setting) {
var _a;
const isDisabled = setting.isDisabled(this.config.config);
const desc = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.description`);
const field = new OO.ui.RadioSelectWidget({
disabled: isDisabled !== undefined && isDisabled !== false &&
!((_a = setting.displayOptions.readOnly) !== null && _a !== void 0 ? _a : false),
items: this.getAllowedValuesArray(settingKey, setting.allowedValues)
.map(([key, label]) => new OO.ui.RadioOptionWidget({
data: key,
label: label,
selected: setting.get() === key
})),
multiselect: false
});
const layout = new OO.ui.FieldLayout(field, {
align: 'top',
label: this.getSettingMsg(settingKey, 'name'),
help: typeof isDisabled === 'string' ?
this.getSettingMsg(settingKey, isDisabled) :
desc.exists() ? desc.text() : undefined,
helpInline: true
});
// OOUIRadioInputWidget
field.on('select', (items) => {
const finalData = Array.isArray(setting.allowedValues) ?
items.data :
setting.allowedValues[items.data];
setting.set(finalData);
this.emit('change');
});
// Attach disabled re-checker
this.on('change', () => {
field.setDisabled(!!setting.isDisabled(this.config.config));
});
return h_1("div", { class: "deputy-setting" }, unwrapWidget(layout));
}
/**
* Creates a new field that acts like a string field.
*
* @param FieldClass
* @param settingKey
* @param setting
* @param extraFieldOptions
* @return A Deputy setting field
*/
newStringLikeField(FieldClass, settingKey, setting, extraFieldOptions = {}) {
var _a, _b, _c;
const isDisabled = setting.isDisabled(this.config.config);
const desc = mw.message(`deputy.setting.${this.mode}.${this.config.group}.${settingKey}.description`);
const field = new FieldClass(Object.assign({ readOnly: (_a = setting.displayOptions.readOnly) !== null && _a !== void 0 ? _a : false, value: (_c = (_b = setting.serialize) === null || _b === void 0 ? void 0 : _b.call(setting, setting.get())) !== null && _c !== void 0 ? _c : setting.get(), disabled: isDisabled !== undefined && isDisabled !== false }, extraFieldOptions));
const layout = new OO.ui.FieldLayout(field, {
align: 'top',
label: this.getSettingMsg(settingKey, 'name'),
help: typeof isDisabled === 'string' ?
this.getSettingMsg(settingKey, isDisabled) :
desc.exists() ? desc.text() : undefined,
helpInline: true
});
if (FieldClass === OO.ui.NumberInputWidget) {
field.on('change', (value) => {
setting.set(+value);
this.emit('change');
});
}
else {
field.on('change', (value) => {
setting.set(value);
this.emit('change');
});
}
// Attach disabled re-checker
this.on('change', () => {
field.setDisabled(setting.isDisabled(this.config.config));
});
return h_1("div", { class: "deputy-setting" }, unwrapWidget(layout));
}
/**
* Creates a new string setting field.
*
* @param settingKey
* @param setting
* @param extraFieldOptions
* @return An HTMLElement of the given setting's field.
*/
newStringField(settingKey, setting, extraFieldOptions) {
return this.newStringLikeField(OO.ui.TextInputWidget, settingKey, setting, extraFieldOptions);
}
/**
* Creates a new number setting field.
*
* @param settingKey
* @param setting
* @param extraFieldOptions
* @return An HTMLElement of the given setting's field.
*/
newNumberField(settingKey, setting, extraFieldOptions) {
return this.newStringLikeField(OO.ui.NumberInputWidget, settingKey, setting, extraFieldOptions);
}
/**
* Creates a new page title setting field.
*
* @param settingKey
* @param setting
* @param extraFieldOptions
* @return An HTMLElement of the given setting's field.
*/
newPageField(settingKey, setting, extraFieldOptions) {
return this.newStringLikeField(mw.widgets.TitleInputWidget, settingKey, setting, extraFieldOptions);
}
/**
* Creates a new code setting field.
*
* @param settingKey
* @param setting
* @param extraFieldOptions
* @return An HTMLElement of the given setting's field.
*/
newCodeField(settingKey, setting, extraFieldOptions) {
return this.newStringLikeField(OO.ui.MultilineTextInputWidget, settingKey, setting, extraFieldOptions);
}
};
}
/**
* Creates a new ConfigurationGroupTabPanel.
*
* @param config Configuration to be passed to the element.
* @return A ConfigurationGroupTabPanel object
*/
function ConfigurationGroupTabPanel (config) {
if (!InternalConfigurationGroupTabPanel$1) {
initConfigurationGroupTabPanel$1();
}
return new InternalConfigurationGroupTabPanel$1(config);
}
/**
* Opens a temporary window. Use this for dialogs that are immediately destroyed
* after running. Do NOT use this for re-openable dialogs, such as the main ANTE
* dialog.
*
* @param window
* @return A promise. Resolves when the window is closed.
*/
function openWindow(window) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((res) => {
let wm = new OO.ui.WindowManager();
document.getElementsByTagName('body')[0].appendChild(unwrapWidget(wm));
wm.addWindows([window]);
wm.openWindow(window);
wm.on('closing', (win, closed) => {
closed.then(() => {
if (wm) {
const _wm = wm;
wm = null;
removeElement(unwrapWidget(_wm));
_wm.destroy();
res();
}
});
});
});
});
}
var deputySettingsEnglish = {
"deputy.about.version": "v$1 ($2)",
"deputy.about": "About",
"deputy.about.homepage": "Homepage",
"deputy.about.openSource": "Source",
"deputy.about.contact": "Contact",
"deputy.about.credit": "Deputy was made with the help of the English Wikipedia Copyright Cleanup WikiProject and the Wikimedia Foundation.",
"deputy.about.license": "Deputy is licensed under the [$1 Apache License 2.0]. The source code for Deputy is available on [$2 GitHub], and is free for everyone to view and suggest changes.",
"deputy.about.thirdParty": "Deputy is bundled with third party libraries to make development easier. All libraries have been vetted for user security and license compatibility. For more information, see the [$1 Licensing] section on Deputy's README.",
"deputy.about.buildInfo": "Deputy v$1 ($2), committed $3.",
"deputy.about.footer": "Made with love, coffee, and the tears of copyright editors.",
"deputy.settings.portlet": "Deputy preferences",
"deputy.settings.portlet.tooltip": "Opens a dialog to modify Deputy preferences",
"deputy.settings.wikiEditIntro.title": "This is a Deputy configuration page",
"deputy.settings.wikiEditIntro.current": "Deputy's active configuration comes from this page. Changing this page will affect the settings of all Deputy users on this wiki. Edit responsibly, and avoid making significant changes without prior discussion.",
"deputy.settings.wikiEditIntro.other": "This is a valid Deputy configuration page, but the configuration is currently being loaded from [[$1]]. If this becomes the active configuration page, changing it will affect the settings of all Deputy users on this wiki. Edit responsibly, and avoid making significant changes without prior discussion.",
"deputy.settings.wikiEditIntro.edit.current": "Modify configuration",
"deputy.settings.wikiEditIntro.edit.other": "Modify this configuration",
"deputy.settings.wikiEditIntro.edit.otherCurrent": "Modify the active configuration",
"deputy.settings.wikiEditIntro.edit.protected": "This page's protection settings do not allow you to edit the page.",
"deputy.settings.wikiOutdated": "Outdated configuration",
"deputy.settings.wikiOutdated.help": "Deputy has detected a change in this wiki's configuration for all Deputy users. We've automatically downloaded the changes for you, but you have to reload to apply the changes.",
"deputy.settings.wikiOutdated.reload": "Reload",
"deputy.settings.dialog.title": "Deputy Preferences",
"deputy.settings.dialog.unimplemented": "A way to modify this setting has not yet been implemented. Check back later!",
"deputy.settings.saved": "Preferences saved. Please refresh the page to see changes.",
"deputy.settings.dialog.wikiConfigWarning": "You are currently editing a wiki-wide Deputy configuration page. Changes made to this page may affect the settings of all Deputy users on this wiki. Edit responsibly, and avoid making significant changes without prior discussion.",
"deputy.setting.user.core": "Deputy",
"deputy.setting.user.core.language.name": "Language",
"deputy.setting.user.core.language.description": "Deputy's interface language. English (US) is used by default, and is used as a fallback if no translations are available. If the content of the wiki you work on is in a different language from the interface language, Deputy will need to load additional data to ensure edit summaries, text, etc., saved on-wiki match the wiki's content language. For this reason, we suggest keeping the interface language the same as the wiki's content language.",
"deputy.setting.user.core.modules.name": "Modules",
"deputy.setting.user.core.modules.description": "Choose the enabled Deputy modules. By default, all modules are enabled.\nDisabling specific modules won't make Deputy load faster, but it can remove\nUI features added by Deputy which may act as clutter when unused.",
"deputy.setting.user.core.modules.cci": "Contributor Copyright Investigations",
"deputy.setting.user.core.modules.ante": "{{int:deputy.ante}}",
"deputy.setting.user.core.modules.ia": "{{int:deputy.ia}}",
"deputy.setting.user.core.portletNames.name": "Portlet names",
"deputy.setting.user.core.portletNames.description": "Choose which names appear in the Deputy portlet (toolbox) links.",
"deputy.setting.user.core.portletNames.full": "Full names (e.g. Attribution Notice Template Editor)",
"deputy.setting.user.core.portletNames.short": "Shortened names (e.g. Attrib. Template Editor)",
"deputy.setting.user.core.portletNames.acronym": "Acronyms (e.g. ANTE)",
"deputy.setting.user.core.dangerMode.name": "Danger mode",
"deputy.setting.user.core.dangerMode.description": "Live on the edge. This disables most confirmations and warnings given by Deputy, only leaving potentially catastrophic actions, such as page edits which break templates. It also adds extra buttons meant for rapid case processing. Intended for clerk use; use with extreme caution.",
"deputy.setting.user.cci": "CCI",
"deputy.setting.user.cci.enablePageToolbar.name": "Enable page toolbar",
"deputy.setting.user.cci.enablePageToolbar.description": "Enables the page toolbar, which is used to quickly show tools, analysis options, and related case information on a page that is the subject of a CCI investigation.",
"deputy.setting.user.cci.showCvLink.name": "Show \"cv\" (\"copyvios\") link for revisions",
"deputy.setting.user.cci.showCvLink.description": "Show a \"cv\" link next to \"cur\" and \"prev\" on revision rows. This link will only appear if this wiki is configured to use Earwig's Copyvio Detector.",
"deputy.setting.user.cci.showUsername.name": "Show username",
"deputy.setting.user.cci.showUsername.description": "Show the username of the user who made the edit on revision rows. This may be redundant for cases which only have one editor.",
"deputy.setting.user.cci.autoCollapseRows.name": "Automatically collapse rows",
"deputy.setting.user.cci.autoCollapseRows.description": "Automatically collapse rows when the page is loaded. This is useful for cases where each row has many revisions, but may be annoying for cases where each row has few revisions.",
"deputy.setting.user.cci.autoShowDiff.name": "Automatically show diffs",
"deputy.setting.user.cci.autoShowDiff.description": "Enabling automatic loading of diffs. Configurable with two additional options to avoid loading too much content.",
"deputy.setting.user.cci.maxRevisionsToAutoShowDiff.name": "Maximum revisions to automatically show diff",
"deputy.setting.user.cci.maxRevisionsToAutoShowDiff.description": "The maximum number of revisions for a given page to automatically show the diff for each revision in the main interface.",
"deputy.setting.user.cci.maxSizeToAutoShowDiff.name": "Maximum size to automatically show diff",
"deputy.setting.user.cci.maxSizeToAutoShowDiff.description": "The maximum size of a diff to be automatically shown, if the diff will be automatically shown (see \"Maximum revisions to automatically show diff\"). Prevents extremely large diffs from opening. Set to -1 to show regardless of size.",
"deputy.setting.user.cci.forceUtc.name": "Force UTC time",
"deputy.setting.user.cci.forceUtc.description": "Forces Deputy to use UTC time whenever displaying dates and times, irregardless of your system's timezone or your MediaWiki time settings.",
"deputy.setting.user.cci.signingBehavior.name": "Row signing behavior",
"deputy.setting.user.cci.signingBehavior.description": "Choose how Deputy should behave when signing rows. By default, all rows are always signed with your signature (~~~~). You may configure Deputy to only sign the last row or never sign. You can also configure Deputy to leave a hidden trace behind (<!-- User:Example|2016-05-28T14:32:12Z -->), which helps Deputy (for other users) determine who assessed a row.",
"deputy.setting.user.cci.signingBehavior.always": "Always sign rows",
"deputy.setting.user.cci.signingBehavior.alwaysTrace": "Always leave a trace",
"deputy.setting.user.cci.signingBehavior.alwaysTraceLastOnly": "Always leave a trace, but sign the last row modified",
"deputy.setting.user.cci.signingBehavior.lastOnly": "Only sign the last row modified (prevents assessor recognition)",
"deputy.setting.user.cci.signingBehavior.never": "Never sign rows (prevents assessor recognition)",
"deputy.setting.user.cci.signSectionArchive.name": "Sign by default when archiving CCI sections",
"deputy.setting.user.cci.signSectionArchive.description": "If enabled, Deputy will enable the \"include my signature\" checkbox by default when archiving a CCI section.",
"deputy.setting.user.cci.openOldOnContinue.name": "Open old versions on continue",
"deputy.setting.user.cci.openOldOnContinue.description": "If enabled, all previously-open sections of a given case page will also be opened alongside the section where the \"continue CCI session\" link was clicked.",
"deputy.setting.user.ante": "ANTE",
"deputy.setting.user.ante.enableAutoMerge.name": "Merge automatically on run",
"deputy.setting.user.ante.enableAutoMerge.description": "If enabled, templates that can be merged will automatically be merged when the dialog opens.",
"deputy.setting.user.ante.enableAutoMerge.unimplemented": "This feature has not yet been implemented.",
"deputy.setting.user.ante.onSubmit.name": "Action on submit",
"deputy.setting.user.ante.onSubmit.description": "Choose what to do after editing attribution notice templates.",
"deputy.setting.user.ante.onSubmit.nothing": "Do nothing",
"deputy.setting.user.ante.onSubmit.reload": "Reload the page",
"deputy.setting.user.ia": "IA",
"deputy.setting.user.ia.responses.name": "Custom responses",
"deputy.setting.user.ia.responses.description": "A custom set of responses, or overrides for existing responses. If an entry\nwith the same key on both the wiki-wide configuration and the user configuration\nexists, the user configuration will override the wiki-wide configuration. Wiki-wide configuration responses can also be disabled locally. If this setting is empty, no overrides are made.",
"deputy.setting.user.ia.enablePageToolbar.name": "Enable page toolbar",
"deputy.setting.user.ia.enablePageToolbar.description": "If enabled, the page toolbar will be shown when dealing with CP cases. The IA page toolbar works slightly differently from the CCI page toolbar. Namely, it shows a button for responding instead of a status dropdown.",
"deputy.setting.user.ia.enablePageToolbar.unimplemented": "This feature has not yet been implemented.",
"deputy.setting.user.ia.defaultEntirePage.name": "Hide entire page by default",
"deputy.setting.user.ia.defaultEntirePage.description": "If enabled, the Infringement Assistant reporting window will hide the entire page by default.",
"deputy.setting.user.ia.defaultFromUrls.name": "Use URLs by default",
"deputy.setting.user.ia.defaultFromUrls.description": "If enabled, the Infringement Assistant reporting window will use URL inputs by default.",
"deputy.setting.user.ia.onHide.name": "Action on hide",
"deputy.setting.user.ia.onHide.description": "Choose what to do after the \"Hide content only\" button is selected and the relevant content is hidden from the page.",
"deputy.setting.user.ia.onHide.nothing": "Do nothing",
"deputy.setting.user.ia.onHide.reload": "Reload the page",
"deputy.setting.user.ia.onHide.redirect": "Redirect to the noticeboard page",
"deputy.setting.user.ia.onSubmit.name": "Action on submit",
"deputy.setting.user.ia.onSubmit.description": "Choose what to do after the \"Submit\" button is selected, the relevant content is hidden from the page, and the page is reported.",
"deputy.setting.user.ia.onSubmit.nothing": "Do nothing",
"deputy.setting.user.ia.onSubmit.reload": "Reload the page",
"deputy.setting.user.ia.onSubmit.redirect": "Redirect to the noticeboard page",
"deputy.setting.user.ia.onBatchSubmit.name": "Action on batch listing submit",
"deputy.setting.user.ia.onBatchSubmit.description": "When reporting a batch of pages, choose what to do after the \"Report\" button is selected and the pages are reported.",
"deputy.setting.user.ia.onBatchSubmit.nothing": "Do nothing",
"deputy.setting.user.ia.onBatchSubmit.reload": "Reload the noticeboard page",
"deputy.setting.wiki.core": "Core",
"deputy.setting.wiki.core.lastEdited.name": "Configuration last edited",
"deputy.setting.wiki.core.lastEdited.description": "The last time that this configuration was edited, as a timestamp. This is a way to ensure that all users are on the correct wiki-wide configuration version before changes are made. Checks are performed on every page load with Deputy.",
"deputy.setting.wiki.core.dispatchRoot.name": "Deputy Dispatch root URL",
"deputy.setting.wiki.core.dispatchRoot.description": "The URL to a Deputy Dispatch instance that can handle this wiki. Deputy Dispatch is a webserver responsible for centralizing and optimizing data used by Deputy, and can be used to reduce load on wikis. More information can be found at https://github.com/ChlodAlejandro/deputy-dispatch.",
"deputy.setting.wiki.core.changeTag.name": "Change tag",
"deputy.setting.wiki.core.changeTag.description": "Tag to use for all Deputy edits.",
"deputy.setting.wiki.cci": "CCI",
"deputy.setting.wiki.cci.enabled.name": "Enable contributor copyright investigations assistant",
"deputy.setting.wiki.cci.enabled.description": "Enables the CCI workflow assistant. This allows Deputy to replace the contribution survey found on CCI case pages with a graphical interface which works with other tabs to make the CCI workflow easier.",
"deputy.setting.wiki.cci.rootPage.name": "Root page",
"deputy.setting.wiki.cci.rootPage.description": "The main page that holds all subpages containing valid contribution copyright investigation cases.",
"deputy.setting.wiki.cci.collapseTop.name": "Collapsible wikitext (top)",
"deputy.setting.wiki.cci.collapseTop.description": "Placed just below a section heading when closing a contributor survey section. Use \"$1\" to denote user comments and signature. On the English Wikipedia, this is {{Template:collapse top}}. Other wikis may have an equivalent template. This should go hand in hand with \"{{int:deputy.setting.wiki.cci.collapseBottom.name}}\", as they are used as a pair.",
"deputy.setting.wiki.cci.collapseBottom.name": "Collapsible wikitext (bottom)",
"deputy.setting.wiki.cci.collapseBottom.description": "Placed at the end of a section when closing a contributor survey section. On the English Wikipedia, this is {{Template:collapse bottom}}. Other wikis may have an equivalent template.",
"deputy.setting.wiki.cci.earwigRoot.name": "Earwig's Copyvio Detector root URL",
"deputy.setting.wiki.cci.earwigRoot.description": "The URL to an instance of Earwig's Copyvio Detector that can handle this wiki. The official copyvio detector (copyvios.toolforge.org) can only handle Wikimedia wikis — you may change this behavior by specifying a custom instance that can process this wiki here.",
"deputy.setting.wiki.ante": "ANTE",
"deputy.setting.wiki.ante.enabled.name": "Enable the Attribution Notice Template Editor",
"deputy.setting.wiki.ante.enabled.description": "Enables ANTE for all users. ANTE is currently the least-optimized module for localization, and may not work for all wikis.",
"deputy.setting.wiki.ia": "IA",
"deputy.setting.wiki.ia.enabled.name": "Enable the Infringement Assistant",
"deputy.setting.wiki.ia.enabled.description": "Enables IA for all users. IA allows users to easily and graphically report pages with suspected or complicated copyright infringements.",
"deputy.setting.wiki.ia.rootPage.name": "Root page",
"deputy.setting.wiki.ia.rootPage.description": "The root page for Infringement Assistant. This should be the copyright problems noticeboard for this specific wiki. IA will only show quick response links for the root page and its subpages.",
"deputy.setting.wiki.ia.subpageFormat.name": "Subpage format",
"deputy.setting.wiki.ia.subpageFormat.description": "The format to use for subpages of the root page. This is a moment.js format string.",
"deputy.setting.wiki.ia.preload.name": "Preload",
"deputy.setting.wiki.ia.preload.description": "Defines the page content to preload the page with if a given subpage does not exist yet. This should be an existing page on-wiki. Leave blank to avoid using a preload entirely.",
"deputy.setting.wiki.ia.allowPresumptive.name": "Allow presumptive deletions",
"deputy.setting.wiki.ia.allowPresumptive.description": "Allows users to file listings for presumptive deletions. Note that the CCI setting \"Root page\" must be set for this to work, even if the \"CCI\" module is disabled entirely.",
"deputy.setting.wiki.ia.listingWikitext.name": "Listing wikitext",
"deputy.setting.wiki.ia.listingWikitext.description": "Defines the wikitext that will be used when adding listings to a noticeboard page. You may use \"$1\" to denote the page being reported, and \"$2\" for user comments (which shouldn't contain the signature).",
"deputy.setting.wiki.ia.listingWikitextMatch.name": "Regular expression for listings",
"deputy.setting.wiki.ia.listingWikitextMatch.description": "A regular expression that will be used to parse and detect listings on a given noticeboard page. Since its usage is rather technical, this value should be edited by someone with technical knowledge of regular expressions. This regular expression must provide three captured groups: group \"$1\" will catch any bullet point, space, or prefix, \"$2\" will catch the page title ONLY if the given page matches \"{{int:deputy.setting.wiki.ia.listingWikitext.name}}\" or \"{{int:deputy.setting.wiki.ia.batchListingWikitext.name}}\", and \"$3\" will catch the page title ONLY IF the page wasn't caught in \"$2\" (such as in cases where there is only a bare link to the page).",
"deputy.setting.wiki.ia.batchListingWikitext.name": "Batch listing wikitext",
"deputy.setting.wiki.ia.batchListingWikitext.description": "Defines the wikitext that will be used when adding batch listings to a noticeboard page. You may use \"$1\" to denote the page being reported, and \"$2\" for the list of pages (as determined by \"{{int:deputy.setting.wiki.ia.batchListingPageWikitext.name}}\") and \"$3\" for user comments (which doesn't contain the signature).",
"deputy.setting.wiki.ia.batchListingPageWikitext.name": "Batch listing page wikitext",
"deputy.setting.wiki.ia.batchListingPageWikitext.description": "Wikitext to use for every row of text in \"{{int:deputy.setting.wiki.ia.batchListingWikitext.name}}\". No line breaks are automatically added; these must be added into this string.",
"deputy.setting.wiki.ia.hideTemplate.name": "Content hiding wikitext (top)",
"deputy.setting.wiki.ia.hideTemplate.description": "Wikitext to hide offending content with. On the English Wikipedia, this is a usage of {{Template:copyvio}}. Other wikis may have an equivalent template. This should go hand in hand with \"{{int:deputy.setting.wiki.ia.hideTemplateBottom.name}}\", as they are used as a pair.",
"deputy.setting.wiki.ia.hideTemplateBottom.name": "Content hiding wikitext (bottom)",
"deputy.setting.wiki.ia.hideTemplateBottom.description": "Placed at the end of hidden content to hide only part of a page. On the English Wikipedia, this is {{Template:copyvio/bottom}}. Other wikis may have an equivalent template.",
"deputy.setting.wiki.ia.responses.name": "Responses",
"deputy.setting.wiki.ia.responses.description": "Quick responses for copyright problems listings. Used by clerks to resolve specific listings or provide more information about the progress of a given listing."
};
/**
* Handles resource fetching operations.
*/
class DeputyResources {
/**
* Loads a resource from the provided resource root.
*
* @param path A path relative to the resource root.
* @return A Promise that resolves to the resource's content as a UTF-8 string.
*/
static loadResource(path) {
return __awaiter(this, void 0, void 0, function* () {
switch (this.root.type) {
case 'url': {
const headers = new Headers();
headers.set('Origin', window.location.origin);
return fetch((new URL(path, this.root.url)).href, {
method: 'GET',
headers
}).then((r) => r.text());
}
case 'wiki': {
this.assertApi();
return getPageContent(this.root.prefix.replace(/\/$/, '') + '/' + path, {}, this.api);
}
}
});
}
/**
* Ensures that `this.api` is a valid ForeignApi.
*/
static assertApi() {
if (this.root.type !== 'wiki') {
return;
}
if (!this.api) {
this.api = new mw.ForeignApi(this.root.wiki.toString(), {
// Force anonymous mode. Deputy doesn't need user data anyway,
// so this should be fine.
anonymous: true
});
}
}
}
/**
* The root of all Deputy resources. This should serve static data that Deputy will
* use to load resources such as language files.
*/
DeputyResources.root = {
type: 'url',
url: new URL('https://tools-static.wmflabs.org/deputy/')
};
var _a;
const USER_LOCALE = (_a = window.deputyLang) !== null && _a !== void 0 ? _a : mw.config.get('wgUserLanguage');
/**
* Handles internationalization and localization for Deputy and sub-modules.
*/
class DeputyLanguage {
/**
* Loads the language for this Deputy interface.
*
* @param module The module to load a language pack for.
* @param fallback A fallback language pack to load. Since this is an actual
* `Record`, this relies on the language being bundled with the userscript. This ensures
* that a language pack is always available, even if a language file could not be loaded.
*/
static load(module, fallback) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const lang = (_a = window.deputyLang) !== null && _a !== void 0 ? _a : 'en';
// The loaded language resource file. Forced to `null` if using English, since English
// is our fallback language.
const langResource = lang === 'en' ? null :
yield DeputyResources.loadResource(`i18n/${module}/${lang}.json`)
.catch(() => {
// Could not find requested language file.
return null;
});
if (!langResource) {
// Fall back.
for (const key in fallback) {
mw.messages.set(key, fallback[key]);
}
return;
}
try {
const langData = JSON.parse(langResource);
for (const key in langData) {
mw.messages.set(key, langData[key]);
}
}
catch (e) {
error(e);
mw.notify(
// No languages to fall back on. Do not translate this string.
'Deputy: Requested language page is not a valid JSON file.', { type: 'error' });
// Fall back.
for (const key in fallback) {
mw.messages.set(key, fallback[key]);
}
}
if (lang !== mw.config.get('wgUserLanguage')) {
yield DeputyLanguage.loadSecondary();
}
});
}
/**
* Loads a specific moment.js locale. It's possible for nothing to be loaded (e.g. if the
* locale is not supported by moment.js), in which case nothing happens and English is
* likely used.
*
* @param locale The locale to load. `window.deputyLang` by default.
*/
static loadMomentLocale(locale = USER_LOCALE) {
return __awaiter(this, void 0, void 0, function* () {
if (locale === 'en') {
// Always loaded.
return;
}
if (mw.loader.getState('moment') !== 'ready') {
// moment.js is not yet loaded.
warn('Deputy tried loading moment.js locales but moment.js is not yet ready.');
return;
}
if (window.moment.locales().indexOf(locale) !== -1) {
// Already loaded.
return;
}
yield mw.loader.using('moment')
.then(() => true, () => null);
yield mw.loader.getScript(new URL(`resources/lib/moment/locale/${locale}.js`, new URL(mw.util.wikiScript('index'), window.location.href)).href).then(() => true, () => null);
});
}
/**
* There are times when the user interface language do not match the wiki content
* language. Since Deputy's edit summary and content strings are found in the
* i18n files, however, there are cases where the wrong language would be used.
*
* This solves this problem by manually overriding content-specific i18n keys with
* the correct language. By default, all keys that match `deputy.*.content.**` get
* overridden.
*
* There are no fallbacks for this. If it fails, the user interface language is
* used anyway. In the event that the user interface language is not English,
* this will cause awkward situations. Whether or not something should be done to
* catch this specific edge case will depend on how frequent it happens.
*
* @param locale
* @param match
*/
static loadSecondary(locale = mw.config.get('wgContentLanguage'), match = /^deputy\.(?:[^.]+)?\.content\./g) {
return __awaiter(this, void 0, void 0, function* () {
// The loaded language resource file. Forced to `null` if using English, since English
// is our fallback language.
const langResource = locale === 'en' ? null :
yield DeputyResources.loadResource(`i18n/${module}/${locale}.json`)
.catch(() => {
// Could not find requested language file.
return null;
});
if (!langResource) {
return;
}
try {
const langData = JSON.parse(langResource);
for (const key in langData) {
if (cloneRegex$1(match).test(key)) {
mw.messages.set(key, langData[key]);
}
}
}
catch (e) {
// Silent failure.
error('Deputy: Requested language page is not a valid JSON file.', e);
}
});
}
}
/**
* Get the nodes from a JQuery object and wraps it in an element.
*
* @param element The element to add the children into
* @param $j The JQuery object
* @return The original element, now with children
*/
function unwrapJQ(element = h_1("span", null), $j) {
$j.each((i, e) => element.append(e));
return element;
}
let InternalConfigurationGroupTabPanel;
/**
* Initializes the process element.
*/
function initConfigurationGroupTabPanel() {
var _a;
InternalConfigurationGroupTabPanel = (_a = class ConfigurationGroupTabPanel extends OO.ui.TabPanelLayout {
/**
*/
constructor() {
super('configurationGroupPage_About');
this.$element.append(h_1("div", null,
h_1("div", { class: "deputy-about" },
h_1("div", { style: "flex: 0" },
h_1("img", { src: ConfigurationGroupTabPanel.logoUrl, alt: "Deputy logo" })),
h_1("div", { style: "flex: 1" },
h_1("div", null,
h_1("div", null, mw.msg('deputy.name')),
h_1("div", null, mw.msg('deputy.about.version', version, gitAbbrevHash))),
h_1("div", null, mw.msg('deputy.description')))),
h_1("div", null,
h_1("a", { href: "https://w.wiki/7NWR", target: "_blank" }, unwrapWidget(new OO.ui.ButtonWidget({
label: mw.msg('deputy.about.homepage'),
flags: ['progressive']
}))),
h_1("a", { href: "https://github.com/ChlodAlejandro/deputy", target: "_blank" }, unwrapWidget(new OO.ui.ButtonWidget({
label: mw.msg('deputy.about.openSource'),
flags: ['progressive']
}))),
h_1("a", { href: "https://w.wiki/7NWS", target: "_blank" }, unwrapWidget(new OO.ui.ButtonWidget({
label: mw.msg('deputy.about.contact'),
flags: ['progressive']
})))),
unwrapJQ(h_1("p", null), mw.message('deputy.about.credit').parseDom()),
unwrapJQ(h_1("p", null), mw.message('deputy.about.license', 'https://www.apache.org/licenses/LICENSE-2.0', 'https://github.com/ChlodAlejandro/deputy').parseDom()),
unwrapJQ(h_1("p", null), mw.message('deputy.about.thirdParty', 'https://github.com/ChlodAlejandro/deputy#licensing').parseDom()),
unwrapJQ(h_1("p", { style: { fontSize: '0.9em', color: 'darkgray' } }), mw.message('deputy.about.buildInfo', gitVersion, gitBranch, new Date(gitDate).toLocaleString()).parseDom()),
unwrapJQ(h_1("p", { style: { fontSize: '0.9em', color: 'darkgray' } }), mw.message('deputy.about.footer').parseDom())));
}
/**
* @return The {@Link Setting}s for this group.
*/
get settings() {
return this.config.config.all[this.config.group];
}
/**
* Sets up the tab item
*/
setupTabItem() {
this.tabItem.setLabel(mw.msg('deputy.about'));
return this;
}
},
_a.logoUrl = 'https://upload.wikimedia.org/wikipedia/commons/2/2b/Deputy_logo.svg',
_a);
}
/**
* Creates a new ConfigurationGroupTabPanel.
*
* @return A ConfigurationGroupTabPanel object
*/
function ConfigurationAboutTabPanel () {
if (!InternalConfigurationGroupTabPanel) {
initConfigurationGroupTabPanel();
}
return new InternalConfigurationGroupTabPanel();
}
let InternalConfigurationDialog;
/**
* Initializes the process element.
*/
function initConfigurationDialog() {
var _a;
InternalConfigurationDialog = (_a = class ConfigurationDialog extends OO.ui.ProcessDialog {
/**
*
* @param data
*/
constructor(data) {
super();
this.config = data.config;
}
/**
* @return The body height of this dialog.
*/
getBodyHeight() {
return 900;
}
/**
* Initializes the dialog.
*/
initialize() {
super.initialize();
this.layout = new OO.ui.IndexLayout();
this.layout.addTabPanels(this.generateGroupLayouts());
if (this.config instanceof UserConfiguration) {
this.layout.addTabPanels([ConfigurationAboutTabPanel()]);
}
this.$body.append(this.layout.$element);
return this;
}
/**
* Generate TabPanelLayouts for each configuration group.
*
* @return An array of TabPanelLayouts
*/
generateGroupLayouts() {
return Object.keys(this.config.all).map((group) => ConfigurationGroupTabPanel({
config: this.config,
group
}));
}
/**
*
* @param action
* @return An OOUI Process.
*/
getActionProcess(action) {
const process = super.getActionProcess();
if (action === 'save') {
process.next(this.config.save());
process.next(() => {
var _a, _b;
mw.notify(mw.msg('deputy.settings.saved'), {
type: 'success'
});
if (this.config.type === 'user') {
// Override local Deputy option, just in case the user wishes to
// change the configuration again.
mw.user.options.set(UserConfiguration.optionKey, this.config.serialize());
if ((_a = window.deputy) === null || _a === void 0 ? void 0 : _a.comms) {
window.deputy.comms.send({
type: 'userConfigUpdate',
config: this.config.serialize()
});
}
}
else if (this.config.type === 'wiki') {
// We know it is a WikiConfiguration, the instanceof check here
// is just for type safety.
if ((_b = window.deputy) === null || _b === void 0 ? void 0 : _b.comms) {
window.deputy.comms.send({
type: 'wikiConfigUpdate',
config: {
title: this.config.sourcePage.getPrefixedText(),
editable: this.config.editable,
wt: this.config.serialize()
}
});
}
// Reload the page.
window.location.reload();
}
});
}
process.next(() => {
this.close();
});
return process;
}
},
_a.static = Object.assign(Object.assign({}, OO.ui.ProcessDialog.static), { name: 'configurationDialog', title: mw.msg('deputy.settings.dialog.title'), size: 'large', actions: [
{
flags: ['safe', 'close'],
icon: 'close',
label: mw.msg('deputy.ante.close'),
title: mw.msg('deputy.ante.close'),
invisibleLabel: true,
action: 'close'
},
{
action: 'save',
label: mw.msg('deputy.save'),
flags: ['progressive', 'primary']
}
] }),
_a);
}
/**
* Creates a new ConfigurationDialog.
*
* @param data
* @return A ConfigurationDialog object
*/
function ConfigurationDialogBuilder(data) {
if (!InternalConfigurationDialog) {
initConfigurationDialog();
}
return new InternalConfigurationDialog(data);
}
let attached = false;
/**
* Spawns a new configuration dialog.
*
* @param config
*/
function spawnConfigurationDialog(config) {
mw.loader.using([
'oojs-ui-core', 'oojs-ui-windows', 'oojs-ui-widgets', 'mediawiki.widgets'
], () => {
const dialog = ConfigurationDialogBuilder({ config });
openWindow(dialog);
});
}
/**
* Attaches the "Deputy preferences" portlet link in the toolbox. Ensures that it doesn't
* get attached twice.
*/
function attachConfigurationDialogPortletLink() {
return __awaiter(this, void 0, void 0, function* () {
if (document.querySelector('#p-deputy-config') || attached) {
return;
}
attached = true;
mw.util.addCSS(deputySettingsStyles);
yield DeputyLanguage.load('settings', deputySettingsEnglish);
mw.util.addPortletLink('p-tb', '#', mw.msg('deputy.settings.portlet'), 'deputy-config', mw.msg('deputy.settings.portlet.tooltip')).addEventListener('click', () => {
// Load a fresh version of the configuration - this way we can make
// modifications live to the configuration without actually affecting
// tool usage.
spawnConfigurationDialog(UserConfiguration.load());
});
});
}
let InternalDeputyMessageWidget;
/**
* Initializes the process element.
*/
function initDeputyMessageWidget() {
InternalDeputyMessageWidget = class DeputyMessageWidget extends OO.ui.MessageWidget {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
var _a;
super(config);
this.$element.addClass('dp-messageWidget');
const elLabel = this.$label[0];
if (!config.label) {
if (config.title) {
elLabel.appendChild(h_1("b", { style: { display: 'block' } }, config.title));
}
if (config.message) {
elLabel.appendChild(h_1("p", { class: "dp-messageWidget-message" }, config.message));
}
}
if (config.actions || config.closable) {
const actionContainer = h_1("div", { class: "dp-messageWidget-actions" });
for (const action of ((_a = config.actions) !== null && _a !== void 0 ? _a : [])) {
if (action instanceof OO.ui.Element) {
actionContainer.appendChild(unwrapWidget(action));
}
else {
actionContainer.appendChild(action);
}
}
if (config.closable) {
const closeButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.dismiss')
});
closeButton.on('click', () => {
removeElement(unwrapWidget(this));
this.emit('close');
});
actionContainer.appendChild(unwrapWidget(closeButton));
}
elLabel.appendChild(actionContainer);
}
}
};
}
/**
* Creates a new DeputyMessageWidget. This is an extension of the default
* OOUI MessageWidget. It includes support for a title, a message, and button
* actions.
*
* @param config Configuration to be passed to the element.
* @return A DeputyMessageWidget object
*/
function DeputyMessageWidget (config) {
if (!InternalDeputyMessageWidget) {
initDeputyMessageWidget();
}
return new InternalDeputyMessageWidget(config);
}
/**
* @param config The current configuration (actively loaded, not the one being viewed)
* @return An HTML element consisting of an OOUI MessageWidget
*/
function WikiConfigurationEditIntro(config) {
const current = config.onConfigurationPage();
let buttons;
if (current) {
const editCurrent = new OO.ui.ButtonWidget({
flags: ['progressive', 'primary'],
label: mw.msg('deputy.settings.wikiEditIntro.edit.current'),
disabled: !mw.config.get('wgIsProbablyEditable'),
title: mw.config.get('wgIsProbablyEditable') ?
undefined : mw.msg('deputy.settings.wikiEditIntro.edit.protected')
});
editCurrent.on('click', () => {
spawnConfigurationDialog(config);
});
buttons = [editCurrent];
}
else {
const editCurrent = new OO.ui.ButtonWidget({
flags: ['progressive', 'primary'],
label: mw.msg('deputy.settings.wikiEditIntro.edit.otherCurrent'),
disabled: !config.editable,
title: config.editable ?
undefined : mw.msg('deputy.settings.wikiEditIntro.edit.protected')
});
editCurrent.on('click', () => __awaiter(this, void 0, void 0, function* () {
spawnConfigurationDialog(config);
}));
const editOther = new OO.ui.ButtonWidget({
flags: ['progressive'],
label: mw.msg('deputy.settings.wikiEditIntro.edit.other'),
disabled: !mw.config.get('wgIsProbablyEditable'),
title: mw.config.get('wgIsProbablyEditable') ?
undefined : mw.msg('deputy.settings.wikiEditIntro.edit.protected')
});
editOther.on('click', () => __awaiter(this, void 0, void 0, function* () {
spawnConfigurationDialog(yield config.static.load(normalizeTitle()));
}));
buttons = [editCurrent, editOther];
}
const messageBox = DeputyMessageWidget({
classes: [
'deputy', 'dp-mb'
],
type: 'notice',
title: mw.msg('deputy.settings.wikiEditIntro.title'),
message: current ?
mw.msg('deputy.settings.wikiEditIntro.current') :
unwrapJQ(h_1("span", null), mw.message('deputy.settings.wikiEditIntro.other', config.sourcePage.getPrefixedText()).parseDom()),
actions: buttons
});
const box = unwrapWidget(messageBox);
box.classList.add('deputy', 'deputy-wikiConfig-intro');
return box;
}
/* eslint-disable max-len */
/*
* Replacement polyfills for wikis that have no configured templates.
* Used in WikiConfiguration, to permit a seamless OOB experience.
*/
/** `{{collapse top}}` equivalent */
const collapseTop = `
{| class="mw-collapsible mw-collapsed" style="border:1px solid #C0C0C0;width:100%"
! <div style="background:#CCFFCC;">$1</div>
|-
|
`.trimStart();
/** `{{collapse bottom}}` equivalent */
const collapseBottom = `
|}`;
/** `* {{subst:article-cv|1=$1}} $2 ~~~~` equivalent */
const listingWikitext = '* [[$1]] $2 ~~~~';
/**
* Polyfill for the following:
* `; {{anchor|1={{delink|$1}}}} $1
* $2
* $3 ~~~~`
*/
const batchListingWikitext = `*; <span style="display: none;" id="$1"></span> $1
$2
$3`;
/**
* Inserted and chained as part of $2 in `batchListingWikitext`.
* Equivalent of `* {{subst:article-cv|1=$1}}\n`. Newline is intentional.
*/
const batchListingPageWikitext = '* [[$1]]\n';
/**
* `{{subst:copyvio|url=$1|fullpage=$2}}` equivalent
*/
const copyvioTop = `<div style="padding: 8px; border: 4px solid #0298b1;">
<div style="font-size: 1.2rem"><b>{{int:deputy.ia.content.copyvio}}</b></div>
<div>{{int:deputy.ia.content.copyvio.help}}</div>
{{if:$1|<div>{{if:$presumptive|{{int:deputy.ia.content.copyvio.from.pd}} $1|{{int:deputy.ia.content.copyvio.from}} $1}}</div>}}
</div>
<!-- {{int:deputy.ia.content.copyvio.content}} -->
<div class="copyvio" style="display: none">`;
/**
* `{{subst:copyvio/bottom}}` equivalent.
*/
const copyvioBottom = `
</div>`;
/**
* @return A MessageWidget for reloading a page with an outdated configuration.
*/
function ConfigurationReloadBanner() {
const reloadButton = new OO.ui.ButtonWidget({
flags: ['progressive', 'primary'],
label: mw.msg('deputy.settings.wikiOutdated.reload')
});
const messageBox = DeputyMessageWidget({
classes: [
'deputy', 'dp-mb', 'dp-wikiConfigUpdateMessage'
],
type: 'notice',
title: mw.msg('deputy.settings.wikiOutdated'),
message: mw.msg('deputy.settings.wikiOutdated.help'),
actions: [reloadButton]
});
reloadButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
window.location.reload();
}));
const box = unwrapWidget(messageBox);
box.style.fontSize = 'calc(1em * 0.875)';
return box;
}
var WikiConfigurationLocations = [
'MediaWiki:Deputy-config.json',
// Prioritize interface protected page over Project namespace
'User:0xDeadbeef/Deputy/configuration.json',
'Project:Deputy/configuration.json'
];
/**
* Automatically applies a change tag to edits made by the user if
* a change tag was provided in the configuration.
*
* @param config
* @return A spreadable Object containing the `tags` parameter for `action=edit`.
*/
function changeTag(config) {
return config.core.changeTag.get() ?
{ tags: config.core.changeTag.get() } :
{};
}
/**
* Wiki-wide configuration. This is applied to all users of the wiki, and has
* the potential to break things for EVERYONE if not set to proper values.
*
* As much as possible, the correct configuration location should be protected
* to avoid vandalism or bad-faith changes.
*
* This configuration works if specific settings are set. In other words, some
* features of Deputy are disabled unless Deputy has been configured. This is
* to avoid messing with existing on-wiki processes.
*/
class WikiConfiguration extends ConfigurationBase {
/**
*
* @param sourcePage
* @param serializedData
* @param editable Whether the configuration is editable by the current user or not.
*/
constructor(sourcePage, serializedData, editable) {
var _a;
super();
this.sourcePage = sourcePage;
this.serializedData = serializedData;
this.editable = editable;
// Used to avoid circular dependencies.
this.static = WikiConfiguration;
this.core = {
/**
* Numerical code that identifies this config version. Increments for every breaking
* configuration file change.
*/
configVersion: new Setting({
defaultValue: WikiConfiguration.configVersion,
displayOptions: { hidden: true },
alwaysSave: true
}),
lastEdited: new Setting({
defaultValue: 0,
displayOptions: { hidden: true },
alwaysSave: true
}),
dispatchRoot: new Setting({
serialize: (v) => v.href,
deserialize: (v) => new URL(v),
defaultValue: new URL('https://deputy.toolforge.org/'),
displayOptions: { type: 'text' },
alwaysSave: true
}),
changeTag: new Setting({
defaultValue: null,
displayOptions: { type: 'text' }
})
};
this.cci = {
enabled: new Setting({
defaultValue: false,
displayOptions: { type: 'checkbox' }
}),
rootPage: new Setting({
serialize: (v) => v === null || v === void 0 ? void 0 : v.getPrefixedText(),
deserialize: (v) => new mw.Title(v),
defaultValue: null,
displayOptions: { type: 'page' }
}),
headingMatch: new Setting({
defaultValue: '(Page|Article|Local file|File)s? \\d+ (to|through) \\d+',
displayOptions: { type: 'text' }
}),
collapseTop: new Setting({
defaultValue: collapseTop,
displayOptions: { type: 'code' }
}),
collapseBottom: new Setting({
defaultValue: collapseBottom,
displayOptions: { type: 'code' }
}),
earwigRoot: new Setting({
serialize: (v) => v.href,
deserialize: (v) => new URL(v),
defaultValue: new URL('https://copyvios.toolforge.org/'),
displayOptions: { type: 'text' },
alwaysSave: true
}),
resortRows: new Setting({
defaultValue: true,
displayOptions: { type: 'checkbox' }
})
};
this.ante = {
enabled: new Setting({
defaultValue: false,
displayOptions: { type: 'checkbox' }
})
};
this.ia = {
enabled: new Setting({
defaultValue: false,
displayOptions: { type: 'checkbox' }
}),
rootPage: new Setting({
serialize: (v) => v === null || v === void 0 ? void 0 : v.getPrefixedText(),
deserialize: (v) => new mw.Title(v),
defaultValue: null,
displayOptions: { type: 'page' }
}),
subpageFormat: new Setting({
defaultValue: 'YYYY MMMM D',
displayOptions: { type: 'text' }
}),
preload: new Setting({
serialize: (v) => { var _a, _b; return ((_b = (_a = v === null || v === void 0 ? void 0 : v.trim()) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) === 0 ? null : v.trim(); },
defaultValue: null,
displayOptions: { type: 'page' }
}),
allowPresumptive: new Setting({
defaultValue: true,
displayOptions: { type: 'checkbox' }
}),
listingWikitext: new Setting({
defaultValue: listingWikitext,
displayOptions: { type: 'code' }
}),
/**
* $1 - Title of the batch
* $2 - List of pages (newlines should be added in batchListingPageWikitext).
* $3 - User comment
*/
batchListingWikitext: new Setting({
defaultValue: batchListingWikitext,
displayOptions: { type: 'code' }
}),
/**
* $1 - Page to include
*/
batchListingPageWikitext: new Setting({
defaultValue: batchListingPageWikitext,
displayOptions: { type: 'code' }
}),
/**
* @see {@link CopyrightProblemsListing#articleCvRegex}
*
* This should match both normal and batch listings.
*/
listingWikitextMatch: new Setting({
defaultValue: '(\\*\\s*)?\\[\\[([^\\]]+)\\]\\]',
displayOptions: { type: 'code' }
}),
hideTemplate: new Setting({
defaultValue: copyvioTop,
displayOptions: { type: 'code' }
}),
hideTemplateBottom: new Setting({
defaultValue: copyvioBottom,
displayOptions: { type: 'code' }
}),
responses: new Setting(Object.assign(Object.assign({}, Setting.basicSerializers), { defaultValue: null, displayOptions: { type: 'unimplemented' } }))
};
this.type = 'wiki';
this.all = { core: this.core, cci: this.cci, ante: this.ante, ia: this.ia };
/**
* Set to true when this configuration is outdated based on latest data. Usually adds banners
* to UI interfaces saying a new version of the configuration is available, and that it should
* be used whenever possible.
*
* TODO: This doesn't do what the documentations says yet.
*/
this.outdated = false;
if (serializedData) {
this.deserialize(serializedData);
}
if ((_a = window.deputy) === null || _a === void 0 ? void 0 : _a.comms) {
// Communications is available. Register a listener.
window.deputy.comms.addEventListener('wikiConfigUpdate', (e) => {
this.update(Object.assign({}, e.data.config, {
title: normalizeTitle(e.data.config.title)
}));
});
}
}
/**
* Loads the configuration from a set of possible sources.
*
* @param sourcePage The specific page to load from
* @return A WikiConfiguration object
*/
static load(sourcePage) {
return __awaiter(this, void 0, void 0, function* () {
if (sourcePage) {
// Explicit source given. Do not load from local cache.
return this.loadFromWiki(sourcePage);
}
else {
return this.loadFromLocal();
}
});
}
/**
* Loads the wiki configuration from localStorage and/or MediaWiki
* settings. This allows for faster loads at the expense of a (small)
* chance of outdated configuration.
*
* The localStorage layer allows fast browser-based caching. If a user
* is logging in again on another device, the user configuration
* will automatically be sent to the client, lessening turnaround time.
* If all else fails, the configuration will be loaded from the wiki.
*
* @return A WikiConfiguration object.
*/
static loadFromLocal() {
return __awaiter(this, void 0, void 0, function* () {
let configInfo;
// If `mw.storage.get` returns `false` or `null`, it'll be thrown up.
let rawConfigInfo = mw.storage.get(WikiConfiguration.optionKey);
// Try to grab it from user options, if it exists.
if (!rawConfigInfo) {
rawConfigInfo = mw.user.options.get(WikiConfiguration.optionKey);
}
if (typeof rawConfigInfo === 'string') {
try {
configInfo = JSON.parse(rawConfigInfo);
}
catch (e) {
// Bad local! Switch to non-local.
error('Failed to get Deputy wiki configuration', e);
return this.loadFromWiki();
}
}
else {
log('No locally-cached Deputy configuration, pulling from wiki.');
return this.loadFromWiki();
}
if (configInfo) {
return new WikiConfiguration(new mw.Title(configInfo.title.title, configInfo.title.namespace), JSON.parse(configInfo.wt), configInfo.editable);
}
else {
return this.loadFromWiki();
}
});
}
/**
* Loads the configuration from the current wiki.
*
* @param sourcePage The specific page to load from
* @return A WikiConfiguration object
*/
static loadFromWiki(sourcePage) {
return __awaiter(this, void 0, void 0, function* () {
const configPage = sourcePage ? Object.assign({ title: sourcePage }, yield (() => __awaiter(this, void 0, void 0, function* () {
const content = yield getPageContent(sourcePage, {
prop: 'revisions|info',
intestactions: 'edit',
fallbacktext: '{}'
});
return {
wt: content,
editable: content.page.actions.edit
};
}))()) : yield this.loadConfigurationWikitext();
try {
// Attempt save of configuration to local options (if not explicitly loaded)
if (sourcePage == null) {
mw.storage.set(WikiConfiguration.optionKey, JSON.stringify(configPage));
}
return new WikiConfiguration(configPage.title, JSON.parse(configPage.wt), configPage.editable);
}
catch (e) {
error(e, configPage);
mw.hook('deputy.i18nDone').add(function notifyConfigFailure() {
mw.notify(mw.msg('deputy.loadError.wikiConfig'), {
type: 'error'
});
mw.hook('deputy.i18nDone').remove(notifyConfigFailure);
});
return null;
}
});
}
/**
* Loads the wiki-wide configuration from a set of predefined locations.
* See {@link WikiConfiguration#configLocations} for a full list.
*
* @return The string text of the raw configuration, or `null` if a configuration was not found.
*/
static loadConfigurationWikitext() {
return __awaiter(this, void 0, void 0, function* () {
const response = yield MwApi.action.get({
action: 'query',
prop: 'revisions|info',
rvprop: 'content',
rvslots: 'main',
rvlimit: 1,
intestactions: 'edit',
redirects: true,
titles: WikiConfiguration.configLocations.join('|')
});
const redirects = toRedirectsObject(response.query.redirects, response.query.normalized);
for (const page of WikiConfiguration.configLocations) {
const title = normalizeTitle(redirects[page] || page).getPrefixedText();
const pageInfo = response.query.pages.find((p) => p.title === title);
if (!pageInfo.missing) {
return {
title: normalizeTitle(pageInfo.title),
wt: pageInfo.revisions[0].slots.main.content,
editable: pageInfo.actions.edit
};
}
}
return null;
});
}
/**
* Check if the current page being viewed is a valid configuration page.
*
* @param page
* @return `true` if the current page is a valid configuration page.
*/
static isConfigurationPage(page) {
if (page == null) {
page = new mw.Title(mw.config.get('wgPageName'));
}
return this.configLocations.some((v) => equalTitle(page, normalizeTitle(v)));
}
/**
* Check for local updates, and update the local configuration as needed.
*
* @param sourceConfig A serialized version of the configuration based on a wiki
* page configuration load.
*/
update(sourceConfig) {
return __awaiter(this, void 0, void 0, function* () {
// Asynchronously load from the wiki.
let fromWiki;
if (sourceConfig) {
fromWiki = sourceConfig;
}
else {
// Asynchronously load from the wiki.
fromWiki = yield WikiConfiguration.loadConfigurationWikitext();
if (fromWiki == null) {
// No configuration found on the wiki.
return;
}
}
const liveWikiConfig = JSON.parse(fromWiki.wt);
// Attempt save if on-wiki config found and doesn't match local.
// Doesn't need to be from the same config page, since this usually means a new config
// page was made, and we need to switch to it.
if (this.core.lastEdited.get() < liveWikiConfig.core.lastEdited) {
if (liveWikiConfig.core.configVersion > WikiConfiguration.configVersion) {
// Don't update if the config version is higher than ours. We don't want
// to load in the config of a newer version, as it may break things.
// Deputy should load in the newer version of the script soon enough,
// and the config will be parsed by a version that supports it.
warn(`Expected wiki configuration version ${this.core.configVersion.get()}, but found ${liveWikiConfig.core.configVersion}. New configuration will not be loaded.`);
return;
}
else if (liveWikiConfig.core.configVersion < WikiConfiguration.configVersion) {
// Version change detected.
// Do nothing... for now.
// HINT: Update configuration
warn(`Expected wiki configuration version ${this.core.configVersion.get()}, but found ${liveWikiConfig.core.configVersion}. Proceeding anyway...`);
}
const onSuccess = () => {
var _a;
// Only mark outdated after saving, so we don't indirectly cause a save operation
// to cancel.
this.outdated = true;
// Attempt to add site notice.
if (document.querySelector('.dp-wikiConfigUpdateMessage') == null) {
(_a = document.getElementById('siteNotice')) === null || _a === void 0 ? void 0 : _a.insertAdjacentElement('afterend', ConfigurationReloadBanner());
}
};
// If updated from a source config (other Deputy tab), do not attempt to save
// to MediaWiki settings. This is most likely already saved by the original tab
// that sent the comms message.
if (!sourceConfig) {
// Use `liveWikiConfig`, since this contains the compressed version and is more
// bandwidth-friendly.
const rawConfigInfo = JSON.stringify({
title: fromWiki.title,
editable: fromWiki.editable,
wt: JSON.stringify(liveWikiConfig)
});
// Save to local storage.
mw.storage.set(WikiConfiguration.optionKey, rawConfigInfo);
// Save to user options (for faster first-load times).
yield MwApi.action.saveOption(WikiConfiguration.optionKey, rawConfigInfo).then(() => {
var _a;
if ((_a = window.deputy) === null || _a === void 0 ? void 0 : _a.comms) {
// Broadcast the update to other tabs.
window.deputy.comms.send({
type: 'wikiConfigUpdate',
config: {
title: fromWiki.title.getPrefixedText(),
editable: fromWiki.editable,
wt: liveWikiConfig
}
});
}
onSuccess();
}).catch(() => {
// silently fail
});
}
else {
onSuccess();
}
}
});
}
/**
* Saves the configuration on-wiki. Does not automatically generate overrides.
*/
save() {
return __awaiter(this, void 0, void 0, function* () {
// Update last edited number
this.core.lastEdited.set(Date.now());
yield MwApi.action.postWithEditToken(Object.assign(Object.assign({}, changeTag(yield window.deputy.getWikiConfig())), { action: 'edit', title: this.sourcePage.getPrefixedText(), text: JSON.stringify(this.serialize()) }));
});
}
/**
* Check if the current page being viewed is the active configuration page.
*
* @param page
* @return `true` if the current page is the active configuration page.
*/
onConfigurationPage(page) {
return equalTitle(page !== null && page !== void 0 ? page : mw.config.get('wgPageName'), this.sourcePage);
}
/**
* Actually displays the banner which allows an editor to modify the configuration.
*/
displayEditBanner() {
return __awaiter(this, void 0, void 0, function* () {
mw.loader.using(['oojs', 'oojs-ui'], () => {
if (document.getElementsByClassName('deputy-wikiConfig-intro').length > 0) {
return;
}
document.getElementById('mw-content-text').insertAdjacentElement('afterbegin', WikiConfigurationEditIntro(this));
});
});
}
/**
* Shows the configuration edit intro banner, if applicable on this page.
*
* @return void
*/
prepareEditBanners() {
return __awaiter(this, void 0, void 0, function* () {
if (['view', 'diff'].indexOf(mw.config.get('wgAction')) === -1) {
return;
}
if (document.getElementsByClassName('deputy-wikiConfig-intro').length > 0) {
return;
}
if (this.onConfigurationPage()) {
return this.displayEditBanner();
}
else if (WikiConfiguration.isConfigurationPage()) {
return this.displayEditBanner();
}
});
}
}
WikiConfiguration.configVersion = 2;
WikiConfiguration.optionKey = 'userjs-deputy-wiki';
WikiConfiguration.configLocations = WikiConfigurationLocations;
/**
* API communication class
*/
class Dispatch {
/**
* Creates a Deputy API instance.
*/
constructor() { }
/**
* Logs the user out of the API.
*/
logout() {
return __awaiter(this, void 0, void 0, function* () {
// TODO: Make logout API request
yield window.deputy.storage.setKV('api-token', null);
});
}
/**
* Logs in the user. Optional: only used for getting data on deleted revisions.
*/
login() {
return __awaiter(this, void 0, void 0, function* () {
Dispatch.token = yield window.deputy.storage.getKV('api-token');
// TODO: If token, set token
// TODO: If no token, start OAuth flow and make login API request
throw new Error('Unimplemented method.');
});
}
/**
* Returns a fully-formed HTTP URL from a given endpoint. This uses the wiki's
* set Dispatch endpoint and a given target (such as `/v1/revisions`) to get
* the full URL.
*
* @param endpoint The endpoint to get
*/
getEndpoint(endpoint) {
return __awaiter(this, void 0, void 0, function* () {
return new URL(endpoint.replace(/^\/+/, ''), (yield WikiConfiguration.load()).core.dispatchRoot.get()
.href
.replace(/\/+$/, ''));
});
}
}
/**
* Singleton instance.
*/
Dispatch.i = new Dispatch();
/**
*
*/
class DispatchRevisions {
/**
*
*/
constructor() { }
/**
* Gets expanded revision data from the API. This returns a response similar to the
* `revisions` object provided by action=query, but also includes additional information
* relevant (such as the parsed (HTML) comment, diff size, etc.)
*
* @param revisions The revisions to get the data for
* @return An object of expanded revision data mapped by revision IDs
*/
get(revisions) {
return __awaiter(this, void 0, void 0, function* () {
return Requester.fetch(yield Dispatch.i.getEndpoint(`v1/revisions/${mw.config.get('wgWikiID')}`), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Api-User-Agent': `Deputy/${version} (${window.location.hostname})`
},
body: 'revisions=' + revisions.join('|')
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
throw new Error(j.error.info);
}
return j;
})
.then((j) => j.revisions);
});
}
}
/**
* Singleton instance
*/
DispatchRevisions.i = new DispatchRevisions();
var ContributionSurveyRowStatus;
(function (ContributionSurveyRowStatus) {
// The row has not been processed yet.
ContributionSurveyRowStatus[ContributionSurveyRowStatus["Unfinished"] = 0] = "Unfinished";
// The row has a comment but cannot be parsed
ContributionSurveyRowStatus[ContributionSurveyRowStatus["Unknown"] = 1] = "Unknown";
// The row has been processed and violations were found ({{y}})
ContributionSurveyRowStatus[ContributionSurveyRowStatus["WithViolations"] = 2] = "WithViolations";
// The row has been processed and violations were not found ({{n}})
ContributionSurveyRowStatus[ContributionSurveyRowStatus["WithoutViolations"] = 3] = "WithoutViolations";
// The row has been found but the added text is no longer in the existing revision
ContributionSurveyRowStatus[ContributionSurveyRowStatus["Missing"] = 4] = "Missing";
// The row has been processed and text was presumptively removed ({{x}}),
ContributionSurveyRowStatus[ContributionSurveyRowStatus["PresumptiveRemoval"] = 5] = "PresumptiveRemoval";
})(ContributionSurveyRowStatus || (ContributionSurveyRowStatus = {}));
/**
* Represents a contribution survey row. This is an abstraction of the row that can
* be seen on contribution survey pages, which acts as an intermediate between raw
* wikitext and actual HTML content.
*/
class ContributionSurveyRow {
/**
* Creates a new contribution survey row from MediaWiki parser output.
*
* @param casePage The case page of this row
* @param wikitext The wikitext of the row
*/
constructor(casePage, wikitext) {
this.data = new ContributionSurveyRowParser(wikitext).parse();
this.type = this.data.type;
this.casePage = casePage;
this.wikitext = wikitext;
this.title = new mw.Title(this.data.page);
this.extras = this.data.extras;
this.comment = this.data.comments;
this.status = this.originalStatus = this.data.comments == null ?
ContributionSurveyRowStatus.Unfinished :
ContributionSurveyRow.identifyCommentStatus(this.data.comments);
if (ContributionSurveyRow.commentMatchRegex[this.status] != null) {
if (cloneRegex$1((ContributionSurveyRow.commentMatchRegex)[this.status], { pre: '^' }).test(this.comment)) {
this.statusIsolated = 'start';
}
else if (cloneRegex$1((ContributionSurveyRow.commentMatchRegex)[this.status], { post: '$' }).test(this.comment)) {
this.statusIsolated = 'end';
}
else {
this.statusIsolated = false;
}
}
}
/**
* Identifies a row's current status based on the comment's contents.
*
* @param comment The comment to process
* @return The status of the row
*/
static identifyCommentStatus(comment) {
for (const status in ContributionSurveyRow.commentMatchRegex) {
if (cloneRegex$1(ContributionSurveyRow.commentMatchRegex[+status]).test(comment)) {
return +status;
}
}
return ContributionSurveyRowStatus.Unknown;
}
/**
* Guesses the sort order for a given set of revisions.
*
* @param diffs The diffs to guess from.
* @return The sort order
*/
static guessSortOrder(diffs) {
let last = null;
let dateScore = 1;
let dateReverseScore = 1;
let byteScore = 1;
let dateStreak = 0;
let dateReverseStreak = 0;
let byteStreak = 0;
for (const diff of diffs) {
if (last == null) {
last = diff;
}
else {
const diffTimestamp = new Date(diff.timestamp).getTime();
const lastTimestamp = new Date(last.timestamp).getTime();
// The use of the OR operator here has a specific purpose:
// * On the first iteration, we want all streak values to be 1
// * On any other iteration, we want it to increment the streak by 1 if a streak
// exists, or set it to 1 if a streak was broken.
dateStreak =
diffTimestamp > lastTimestamp ? dateStreak + 1 : 0;
dateReverseStreak =
diffTimestamp < lastTimestamp ? dateReverseStreak + 1 : 0;
byteStreak =
diff.diffsize <= last.diffsize ? byteStreak + 1 : 0;
dateScore = (dateScore + ((diffTimestamp > lastTimestamp ? 1 : 0) * (1 + dateStreak * 0.3))) / 2;
dateReverseScore = (dateReverseScore + ((diffTimestamp < lastTimestamp ? 1 : 0) * (1 + dateReverseStreak * 0.3))) / 2;
byteScore = (byteScore + ((diff.diffsize <= last.diffsize ? 1 : 0) * (1 + byteStreak * 0.3))) / 2;
last = diff;
}
}
// Multiply by weights to remove ties
dateScore *= 1.05;
dateReverseScore *= 1.025;
switch (Math.max(dateScore, dateReverseScore, byteScore)) {
case byteScore:
return ContributionSurveyRowSort.Bytes;
case dateScore:
return ContributionSurveyRowSort.Date;
case dateReverseScore:
return ContributionSurveyRowSort.DateReverse;
}
}
/**
* Gets the sorter function which will sort a set of diffs based on a given
* sort order.
*
* @param sort
* @param mode The sort mode to use. If `array`, the returned function sorts an
* array of revisions. If `key`, the returned function sorts entries with the first
* entry element (`entry[0]`) being a revision. If `value`, the returned function
* sorts values with the second entry element (`entry[1]`) being a revision.
* @return The sorted array
*/
static getSorterFunction(sort, mode = 'array') {
return (_a, _b) => {
let a, b;
switch (mode) {
case 'array':
a = _a;
b = _b;
break;
case 'key':
a = _a[0];
b = _b[0];
break;
case 'value':
a = _a[1];
b = _b[1];
break;
}
switch (sort) {
case ContributionSurveyRowSort.Date:
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
case ContributionSurveyRowSort.DateReverse:
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
case ContributionSurveyRowSort.Bytes:
return b.diffsize - a.diffsize;
}
};
}
/**
* This variable returns true when
* (a) the row has a non-unfinished status, and
* (b) there are no outstanding diffs in this row
*
* @return See description.
*/
get completed() {
if (this.diffs == null) {
throw new Error('Diffs have not been pulled yet');
}
return this.status !== ContributionSurveyRowStatus.Unfinished &&
this.diffs.size === 0;
}
/**
* Get the ContributionSurveyRowRevisions of this row.
*
* @param forceReload
*/
getDiffs(forceReload = false) {
return __awaiter(this, void 0, void 0, function* () {
if (this.diffs != null && !forceReload) {
return this.diffs;
}
const revisionData = new Map();
const revids = this.data.revids;
// Load revision information
const toCache = [];
for (const revisionID of revids) {
const cachedDiff = yield window.deputy.storage.db.get('diffCache', revisionID);
if (cachedDiff) {
revisionData.set(revisionID, new ContributionSurveyRevision(this, cachedDiff));
}
else {
toCache.push(revisionID);
}
}
if (toCache.length > 0) {
const expandedData = yield DispatchRevisions.i.get(toCache);
for (const revisionID in expandedData) {
revisionData.set(+revisionID, new ContributionSurveyRevision(this, expandedData[revisionID]));
}
for (const revisionID in expandedData) {
yield window.deputy.storage.db.put('diffCache', expandedData[revisionID]);
}
}
// Load tag messages
// First gather all tags mentioned, and then load messages.
const tags = Array.from(revisionData.values()).reduce((acc, cur) => {
if (cur.tags) {
for (const tag of cur.tags) {
if (acc.indexOf(tag) === -1) {
acc.push(tag);
}
}
}
return acc;
}, ['list-wrapper']);
yield MwApi.action.loadMessagesIfMissing(tags.map((v) => 'tag-' + v), {
amenableparser: true
});
// Sort the rows (if rearranging is enabled)
if (window.deputy.wikiConfig.cci.resortRows.get()) {
const sortOrder = ContributionSurveyRow.guessSortOrder(revisionData.values());
return this.diffs = new Map([...revisionData.entries()].sort(ContributionSurveyRow.getSorterFunction(sortOrder, 'value')));
}
else {
return this.diffs = new Map([...revisionData.entries()]);
}
});
}
/**
* Gets the comment with status indicator removed.
*
* @return The comment with the status indicator removed.
*/
getActualComment() {
if (this.originalStatus === ContributionSurveyRowStatus.Unfinished) {
return '';
}
else if (this.statusIsolated === false ||
this.originalStatus === ContributionSurveyRowStatus.Unknown) {
return this.comment;
}
else if (this.statusIsolated === 'start') {
return this.comment.replace(cloneRegex$1((ContributionSurveyRow.commentMatchRegex)[this.originalStatus], { pre: '^' }), '').trim();
}
else if (this.statusIsolated === 'end') {
return this.comment.replace(cloneRegex$1((ContributionSurveyRow.commentMatchRegex)[this.originalStatus], { post: '$' }), '').trim();
}
return '';
}
}
ContributionSurveyRow.Parser = ContributionSurveyRowParser;
/**
* A set of regular expressions that will match a specific contribution survey row
* comment. Used to determine the status of the comment.
*/
ContributionSurveyRow.commentMatchRegex = {
// TODO: Wiki localization
[ContributionSurveyRowStatus.WithViolations]: /\{\{(aye|y)}}/gi,
[ContributionSurveyRowStatus.WithoutViolations]: /\{\{n(ay)?}}/gi,
[ContributionSurveyRowStatus.Missing]: /\{\{\?}}/gi,
[ContributionSurveyRowStatus.PresumptiveRemoval]: /\{\{x}}/gi
};
/**
* Swaps two elements in the DOM. Element 1 will be removed from the DOM, Element 2 will
* be added in its place.
*
* @param element1 The element to remove
* @param element2 The element to insert
* @return `element2`, for chaining
*/
function swapElements (element1, element2) {
try {
element1.insertAdjacentElement('afterend', element2);
element1.parentElement.removeChild(element1);
return element2;
}
catch (e) {
error(e, { element1, element2 });
// Caught for debug only. Rethrow.
throw e;
}
}
/**
* Loading dots.
*
* @return Loading dots.
*/
function DeputyLoadingDots () {
return h_1("div", { class: "dp--loadingDots" },
h_1("span", { class: "dp-loadingDots-1" }),
h_1("span", { class: "dp-loadingDots-2" }),
h_1("span", { class: "dp-loadingDots-3" }));
}
/**
* Gets the URL of a permanent link page.
*
* @param revid The revision ID to link to
* @param page The title of the page to compare to
* @param includeCurrentParams `true` if the current query parameters should be included
* @return The URL of the diff page
*/
function getRevisionURL (revid, page, includeCurrentParams = false) {
const url = new URL(window.location.href);
url.pathname = mw.util.wikiScript('index');
const searchParams = url.searchParams;
if (!includeCurrentParams) {
for (const key of Array.from(searchParams.keys())) {
searchParams.delete(key);
}
}
searchParams.set('title', normalizeTitle(page).getPrefixedText());
searchParams.set('oldid', `${revid}`);
url.search = '?' + searchParams.toString();
url.hash = '';
return url.toString();
}
/**
* Gets the URL of a diff page.
*
* @param from The revision to compare with
* @param to The revision to compare from
* @param includeCurrentParams `true` if the current query parameters should be included
* @return The URL of the diff page
*/
function getRevisionDiffURL (from, to, includeCurrentParams = false) {
const url = new URL(window.location.href);
url.pathname = mw.util.wikiScript('index');
const searchParams = url.searchParams;
if (!includeCurrentParams) {
for (const key of Array.from(searchParams.keys())) {
searchParams.delete(key);
}
}
if (to != null) {
searchParams.set('diff', to.toString());
searchParams.set('oldid', from.toString());
}
else {
searchParams.set('diff', from.toString());
// Strip oldid from URL.
if (searchParams.has('oldid')) {
searchParams.delete('oldid');
}
}
url.search = '?' + searchParams.toString();
url.hash = '';
return url.toString();
}
/**
* Gets the namespace ID from a canonical (not localized) namespace name.
*
* @param namespace The namespace to get
* @return The namespace ID
*/
function nsId(namespace) {
return mw.config.get('wgNamespaceIds')[namespace.toLowerCase().replace(/ /g, '_')];
}
/**
* Evaluates any string using `mw.msg`. This handles internationalization of strings
* that are loaded outside the script or asynchronously.
*
* @param string The string to evaluate
* @param {...any} parameters Parameters to pass, if any
* @return A mw.Message
*/
function msgEval(string, ...parameters) {
// Named parameters
let named = {};
if (typeof parameters[0] === 'object') {
named = parameters.shift();
}
const m = new mw.Map();
for (const [from, to] of Object.entries(named)) {
string = string.replace(new RegExp(`\\$${from}`, 'g'), to);
}
m.set('msg', string);
return new mw.Message(m, 'msg', parameters);
}
/**
* Mixes values together into a string for the `class` attribute.
*
* @param {...any} classes
* @return string
*/
function classMix (...classes) {
const processedClasses = [];
for (const _class of classes) {
if (Array.isArray(_class)) {
processedClasses.push(..._class);
}
else {
processedClasses.push(_class);
}
}
return processedClasses.filter((v) => v != null && !!v).join(' ');
}
/**
* @param root0
* @param root0.revid
* @param root0.parentid
* @param root0.missing
* @return HTML element
*/
function ChangesListLinks({ revid: _revid, parentid: _parentid, missing }) {
const cur = getRevisionDiffURL(_revid, 'cur');
const prev = missing ?
getRevisionDiffURL(_revid, 'prev') :
getRevisionDiffURL(_parentid, _revid);
let cv;
if (window.deputy &&
window.deputy.config.cci.showCvLink &&
window.deputy.wikiConfig.cci.earwigRoot) {
cv = new URL('', window.deputy.wikiConfig.cci.earwigRoot.get());
const selfUrl = new URL(window.location.href);
const urlSplit = selfUrl.hostname.split('.').reverse();
const proj = urlSplit[1]; // wikipedia
const lang = urlSplit[2]; // en
// Cases where the project/lang is unsupported (e.g. proj = "facebook", for example)
// should be handled by Earwig's.
cv.searchParams.set('action', 'search');
cv.searchParams.set('lang', lang);
cv.searchParams.set('project', proj);
cv.searchParams.set('oldid', `${_revid}`);
cv.searchParams.set('use_engine', '0');
cv.searchParams.set('use_links', '1');
}
return h_1("span", { class: "mw-changeslist-links" },
h_1("span", null,
h_1("a", { rel: "noopener", href: cur, title: mw.msg('deputy.session.revision.cur.tooltip'), target: "_blank" }, mw.msg('deputy.revision.cur'))),
h_1("span", null, (!_parentid && !missing) ?
mw.msg('deputy.session.revision.prev') :
h_1("a", { rel: "noopener", href: prev, title: mw.msg('deputy.session.revision.prev.tooltip'), target: "_blank" }, mw.msg('deputy.revision.prev'))),
!!window.deputy.config.cci.showCvLink &&
cv &&
h_1("span", null,
h_1("a", { rel: "noopener", href: cv.toString(), title: mw.msg('deputy.session.revision.cv.tooltip'), target: "_blank" }, mw.msg('deputy.session.revision.cv'))));
}
/**
* @return HTML element
*/
function NewPageIndicator() {
return h_1("abbr", { class: "newpage", title: mw.msg('deputy.revision.new.tooltip') }, mw.msg('deputy.revision.new'));
}
/**
* @param root0
* @param root0.timestamp
* @return HTML element
*/
function ChangesListTime({ timestamp }) {
const time = new Date(timestamp);
const formattedTime = time.toLocaleTimeString(USER_LOCALE, {
hourCycle: 'h24',
timeStyle: mw.user.options.get('date') === 'ISO 8601' ? 'long' : 'short'
});
return h_1("span", { class: "mw-changeslist-time" }, formattedTime);
}
/**
* @param root0
* @param root0.revision
* @param root0.link
* @return HTML element
*/
function ChangesListDate({ revision, link }) {
var _a;
// `texthidden` would be indeterminate if the `{timestamp}` type was used
if (revision.texthidden) {
// Don't give out a link if the revision was deleted
link = false;
}
const time = new Date(revision.timestamp);
let now = window.moment(time);
if (window.deputy && window.deputy.config.cci.forceUtc.get()) {
now = now.utc();
}
const formattedTime = time.toLocaleTimeString(USER_LOCALE, {
hourCycle: 'h24',
timeStyle: mw.user.options.get('date') === 'ISO 8601' ? 'long' : 'short',
timeZone: ((_a = window.deputy) === null || _a === void 0 ? void 0 : _a.config.cci.forceUtc.get()) ? 'UTC' : undefined
});
const formattedDate = now.locale(USER_LOCALE).format({
dmy: 'D MMMM YYYY',
mdy: 'MMMM D, Y',
ymd: 'YYYY MMMM D',
'ISO 8601': 'YYYY:MM:DD[T]HH:mm:SS'
}[mw.user.options.get('date')]);
const comma = mw.msg('comma-separator');
return link !== false ?
h_1("a", { class: "mw-changeslist-date", href: getRevisionURL(revision.revid, revision.page.title) },
formattedTime,
comma,
formattedDate) :
h_1("span", { class: classMix('mw-changeslist-date', revision.texthidden && 'history-deleted') },
formattedTime,
comma,
formattedDate);
}
/**
* @param root0
* @param root0.revision
* @return HTML element
*/
function ChangesListUser({ revision }) {
const { user, userhidden } = revision;
if (userhidden) {
return h_1("span", { class: "history-user" },
h_1("span", { class: "history-deleted mw-userlink" }, mw.msg('deputy.revision.removed.user')));
}
const userPage = new mw.Title(user, nsId('user'));
const userTalkPage = new mw.Title(user, nsId('user_talk'));
const userContribsPage = new mw.Title('Special:Contributions/' + user);
return h_1("span", { class: "history-user" },
h_1("a", { class: "mw-userlink", target: "_blank", rel: "noopener", href: mw.format(mw.config.get('wgArticlePath'), userPage.getPrefixedDb()), title: userPage.getPrefixedText() }, userPage.getMainText()),
" ",
h_1("span", { class: "mw-usertoollinks mw-changeslist-links" },
h_1("span", null,
h_1("a", { class: "mw-usertoollinks-talk", target: "_blank", rel: "noopener", href: mw.format(mw.config.get('wgArticlePath'), userTalkPage.getPrefixedDb()), title: userTalkPage.getPrefixedText() }, mw.msg('deputy.revision.talk'))),
" ",
h_1("span", null,
h_1("a", { class: "mw-usertoollinks-contribs", target: "_blank", rel: "noopener", href: mw.format(mw.config.get('wgArticlePath'), userContribsPage.getPrefixedDb()), title: userContribsPage.getPrefixedText() }, mw.msg('deputy.revision.contribs')))));
}
/**
* @param root0
* @param root0.size
* @return HTML element
*/
function ChangesListBytes({ size }) {
return h_1("span", { class: "history-size mw-diff-bytes", "data-mw-bytes": size }, mw.message('deputy.revision.bytes', size.toString()).text());
}
/**
* @param root0
* @param root0.diffsize
* @param root0.size
* @return HTML element
*/
function ChangesListDiff({ diffsize, size }) {
const DiffTag = (Math.abs(diffsize) > 500 ?
'strong' :
'span');
return h_1(DiffTag, { class: `mw-plusminus-${!diffsize ? 'null' :
(diffsize > 0 ? 'pos' : 'neg')} mw-diff-bytes`, title: diffsize == null ?
mw.msg('deputy.brokenDiff.explain') :
mw.message('deputy.revision.byteChange', size.toString()).text() }, diffsize == null ?
mw.msg('deputy.brokenDiff') :
// Messages that can be used here:
// * deputy.negativeDiff
// * deputy.positiveDiff
// * deputy.zeroDiff
mw.message(`deputy.${{
'-1': 'negative',
1: 'positive',
0: 'zero'
}[Math.sign(diffsize)]}Diff`, diffsize.toString()).text());
}
/**
* @param root0
* @param root0.page
* @param root0.page.title
* @param root0.page.ns
* @return HTML element
*/
function ChangesListPage({ page }) {
const pageTitle = new mw.Title(page.title, page.ns).getPrefixedText();
return h_1("a", { class: "mw-contributions-title", href: mw.util.getUrl(pageTitle), title: pageTitle }, pageTitle);
}
/**
* @param root0
* @param root0.tags
* @return HTML element
*/
function ChangesListTags({ tags }) {
return h_1("span", { class: "mw-tag-markers" },
h_1("a", { rel: "noopener", href: mw.format(mw.config.get('wgArticlePath'), 'Special:Tags'), title: "Special:Tags", target: "_blank" }, mw.message('deputy.revision.tags', tags.length.toString()).text()),
tags.map((v) => {
// eslint-disable-next-line mediawiki/msg-doc
const tagMessage = mw.message(`tag-${v}`).parseDom();
return [
' ',
tagMessage.text() !== '-' && unwrapJQ(h_1("span", { class: `mw-tag-marker mw-tag-marker-${v}` }), tagMessage)
];
}));
}
/**
*
* @param root0
* @param root0.revision
*/
function ChangesListMissingRow({ revision }) {
return h_1("span", null,
h_1(ChangesListLinks, { revid: revision.revid, parentid: revision.parentid, missing: true }),
' ',
h_1("i", { dangerouslySetInnerHTML: mw.message('deputy.session.revision.missing', revision.revid).parse() }));
}
/**
* @param root0
* @param root0.revision
* @param root0.format
* @return A changes list row.
*/
function ChangesListRow({ revision, format }) {
var _a, _b;
if (!format) {
format = 'history';
}
let commentElement = '';
if (revision.commenthidden) {
commentElement = h_1("span", { class: "history-deleted comment" }, mw.msg('deputy.revision.removed.comment'));
}
else if (revision.parsedcomment) {
commentElement = h_1("span", { class: "comment comment--without-parentheses",
/** Stranger danger! Yes. */
dangerouslySetInnerHTML: revision.parsedcomment });
}
else if (revision.comment) {
const comment = revision.comment
// Insert Word-Joiner to avoid parsing "templates".
.replace(/{/g, '{\u2060')
.replace(/}/g, '\u2060}');
commentElement = unwrapJQ(h_1("span", { class: "comment comment--without-parentheses" }), msgEval(comment).parseDom());
}
return h_1("span", null,
h_1(ChangesListLinks, { revid: revision.revid, parentid: revision.parentid }),
" ",
!revision.parentid && h_1(NewPageIndicator, null),
h_1(ChangesListTime, { timestamp: revision.timestamp }),
h_1(ChangesListDate, { revision: revision }),
" ",
format === 'history' && h_1(ChangesListUser, { revision: revision }),
" ",
h_1("span", { class: "mw-changeslist-separator" }),
" ",
format === 'history' && h_1(ChangesListBytes, { size: revision.size }),
" ",
h_1(ChangesListDiff, { size: revision.size, diffsize: revision.diffsize }),
" ",
h_1("span", { class: "mw-changeslist-separator" }),
" ",
format === 'contribs' && h_1(ChangesListPage, { page: revision.page }),
" ",
commentElement,
" ",
((_b = (_a = revision.tags) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : -1) > 0 &&
h_1(ChangesListTags, { tags: revision.tags }));
}
/**
* Get the API error text from an API response.
*
* @param errorData
* @param n Get the `n`th error. Defaults to 0 (first error).
*/
function getApiErrorText(errorData, n = 0) {
var _a, _b, _c, _d, _e, _f, _g;
// errorformat=html
return ((_b = (_a = errorData.errors) === null || _a === void 0 ? void 0 : _a[n]) === null || _b === void 0 ? void 0 : _b.html) ?
h_1("span", { dangerouslySetInnerHTML: (_d = (_c = errorData.errors) === null || _c === void 0 ? void 0 : _c[n]) === null || _d === void 0 ? void 0 : _d.html }) :
(
// errorformat=plaintext/wikitext
(_g = (_f = (_e = errorData.errors) === null || _e === void 0 ? void 0 : _e[n]) === null || _f === void 0 ? void 0 : _f.text) !== null && _g !== void 0 ? _g :
// errorformat=bc
errorData.info);
}
/**
* A specific revision for a section row.
*/
class DeputyContributionSurveyRevision extends EventTarget {
/**
* @param revision
* @param row
* @param options
* @param options.expanded
*/
constructor(revision, row, options = {}) {
var _a;
super();
this.revisionStatusUpdateListener = this.onRevisionStatusUpdate.bind(this);
/**
* The diff view of the given revision. May also be "loading" text, or
* null if the diff view has not yet been set.
* @private
*/
this.diff = null;
this.revision = revision;
this.uiRow = row;
this.autoExpanded = (_a = options.expanded) !== null && _a !== void 0 ? _a : false;
if (this.statusAutosaveFunction == null) {
// TODO: types-mediawiki limitation
this.statusAutosaveFunction = mw.util.throttle(() => __awaiter(this, void 0, void 0, function* () {
yield this.saveStatus();
}), 500);
}
}
/**
* @return `true` the current revision has been checked by the user or `false` if not.
*/
get completed() {
var _a, _b;
return (_b = (_a = this.completedCheckbox) === null || _a === void 0 ? void 0 : _a.isSelected()) !== null && _b !== void 0 ? _b : false;
}
/**
* Set the value of the completed checkbox.
*
* @param value The new value
*/
set completed(value) {
var _a;
(_a = this.completedCheckbox) === null || _a === void 0 ? void 0 : _a.setSelected(value);
}
/**
* @return The hash used for autosave keys
*/
get autosaveHash() {
return `CASE--${this.uiRow.row.casePage.title.getPrefixedDb()}+PAGE--${this.uiRow.row.title.getPrefixedDb()}+REVISION--${this.revision.revid}`;
}
/**
* Save the status and comment for this row to DeputyStorage.
*/
saveStatus() {
return __awaiter(this, void 0, void 0, function* () {
if (this.completed) {
yield window.deputy.storage.db.put('diffStatus', {
hash: this.autosaveHash
});
}
else {
yield window.deputy.storage.db.delete('diffStatus', this.autosaveHash);
}
});
}
/**
* Gets the database-saved status. Used for getting the autosaved values of the status and
* closing comments.
*/
getSavedStatus() {
return __awaiter(this, void 0, void 0, function* () {
return (yield window.deputy.storage.db.get('diffStatus', this.autosaveHash)) != null;
});
}
/**
* Listener for revision status updates from the root session.
*
* @param root0
* @param root0.data
*/
onRevisionStatusUpdate({ data }) {
if (this.uiRow.row.casePage.pageId === data.caseId &&
this.uiRow.row.title.getPrefixedText() === data.page &&
this.revision.revid === data.revision) {
this.completed = data.status;
window.deputy.comms.reply(data, {
type: 'acknowledge'
});
}
}
/**
* Performs cleanup before removal.
*/
close() {
window.deputy.comms.removeEventListener('revisionStatusUpdate', this.revisionStatusUpdateListener);
}
/**
* Prepares the completed checkbox (and preload it with a check if it's been saved in
* the cache).
*/
prepare() {
return __awaiter(this, void 0, void 0, function* () {
this.completedCheckbox = new OO.ui.CheckboxInputWidget({
title: mw.msg('deputy.session.revision.assessed'),
selected: yield this.getSavedStatus(),
classes: ['dp-cs-rev-checkbox']
});
this.completedCheckbox.on('change', (checked) => {
var _a, _b, _c;
this.dispatchEvent(new CustomEvent('update', {
detail: {
checked: checked,
revision: this.revision
}
}));
window.deputy.comms.send({
type: 'revisionStatusUpdate',
caseId: this.uiRow.row.casePage.pageId,
page: this.uiRow.row.title.getPrefixedText(),
revision: this.revision.revid,
status: checked,
nextRevision: (_c = (_b = (_a = this.uiRow.revisions) === null || _a === void 0 ? void 0 : _a.find((revision) => !revision.completed &&
revision.revision.revid !== this.revision.revid)) === null || _b === void 0 ? void 0 : _b.revision.revid) !== null && _c !== void 0 ? _c : null
});
this.statusAutosaveFunction();
});
this.diffToggle = new OO.ui.ToggleButtonWidget({
label: mw.msg('deputy.session.revision.diff.toggle'),
invisibleLabel: true,
indicator: 'down',
framed: false,
classes: ['dp-cs-rev-toggleDiff'],
value: this.autoExpanded
});
this.diff = h_1("div", { class: "dp-cs-rev-diff" });
let loaded = false;
const handleDiffToggle = (active) => {
this.diffToggle.setIndicator(active ? 'up' : 'down');
if (active && this.diff.classList.contains('dp-cs-rev-diff--errored')) {
// Remake diff panel
this.diff = swapElements(this.diff, h_1("div", { class: "dp-cs-rev-diff" }));
}
else if (loaded) {
this.diff.classList.toggle('dp-cs-rev-diff--hidden', !active);
}
if (active && !loaded) {
// Going active, clear the element out
Array.from(this.diff.children).forEach((child) => this.diff.removeChild(child));
this.diff.setAttribute('class', 'dp-cs-rev-diff');
this.diff.appendChild(h_1(DeputyLoadingDots, null));
const comparePromise = MwApi.action.get({
action: 'compare',
fromrev: this.revision.revid,
torelative: 'prev',
prop: 'diff'
});
const stylePromise = mw.loader.using('mediawiki.diff.styles');
// Promise.all not used here since we need to use JQuery.Promise#then
// if we want to access the underlying error response.
$.when([comparePromise, stylePromise])
.then((results) => results[0])
.then((data) => {
unwrapWidget(this.diffToggle).classList.add('dp-cs-rev-toggleDiff--loaded');
// Clear element out again
Array.from(this.diff.children).forEach((child) => this.diff.removeChild(child));
// https://youtrack.jetbrains.com/issue/WEB-61047
// noinspection JSXDomNesting
const diffTable = h_1("table", { class: classMix('diff', `diff-editfont-${mw.user.options.get('editfont')}`) },
h_1("colgroup", null,
h_1("col", { class: "diff-marker" }),
h_1("col", { class: "diff-content" }),
h_1("col", { class: "diff-marker" }),
h_1("col", { class: "diff-content" })));
// Trusted .innerHTML (data always comes from MediaWiki Action API)
diffTable.innerHTML += data.compare.body;
diffTable.querySelectorAll('tr').forEach((tr) => {
// Delete all header rows
if (tr.querySelector('.diff-lineno')) {
removeElement(tr);
return;
}
// Delete all no-change rows (gray rows)
// !(.diff-markers with a marker) = no change for row
if (!tr.querySelector('td.diff-marker[data-marker]')) {
removeElement(tr);
}
});
this.diff.classList.toggle('dp-cs-rev-diff--loaded', true);
this.diff.classList.toggle('dp-cs-rev-diff--errored', false);
this.diff.appendChild(diffTable);
loaded = true;
}, (_error, errorData) => {
// Clear element out again
Array.from(this.diff.children).map((child) => this.diff.removeChild(child));
this.diff.classList.toggle('dp-cs-rev-diff--loaded', true);
this.diff.classList.toggle('dp-cs-rev-diff--errored', true);
this.diff.appendChild(unwrapWidget(DeputyMessageWidget({
type: 'error',
message: mw.msg('deputy.session.revision.diff.error', errorData ?
getApiErrorText(errorData) :
_error.message)
})));
});
}
};
this.diffToggle.on('change', (checked) => {
handleDiffToggle(checked);
});
if (this.autoExpanded) {
handleDiffToggle(true);
}
});
}
/**
* @inheritDoc
*/
render() {
var _a;
window.deputy.comms.addEventListener('revisionStatusUpdate', this.revisionStatusUpdateListener);
// Be wary of the spaces between tags.
return this.element = h_1("div", { class: ((_a = this.revision.tags) !== null && _a !== void 0 ? _a : []).map((v) => 'mw-tag-' + v
.replace(/[^A-Z0-9-]/gi, '')
.replace(/\s/g, '_')).join(' ') },
unwrapWidget(this.completedCheckbox),
unwrapWidget(this.diffToggle),
this.revision.missing ?
h_1(ChangesListMissingRow, { revision: this.revision }) :
h_1(ChangesListRow, { revision: this.revision }),
this.diff);
}
/**
* Sets the disabled state of this section.
*
* @param disabled
*/
setDisabled(disabled) {
var _a;
(_a = this.completedCheckbox) === null || _a === void 0 ? void 0 : _a.setDisabled(disabled);
this.disabled = disabled;
}
}
/**
* Attempt to guess the author of a comment from the comment signature.
*
* @param comment The comment to read.
* @return The author of the comment
*/
function guessAuthor (comment) {
const userRegex = /\[\[User(?:[ _]talk)?:([^|\]]+?)(?:\|[^\]]+?|]])/g;
const matches = [];
let match = userRegex.exec(comment);
while (match != null) {
matches.push(match[1]);
match = userRegex.exec(comment);
}
return matches.length === 0 ? null : matches[matches.length - 1];
}
/**
* Used for detecting Deputy traces.
*/
const traceRegex = /<!--\s*(?:User:)?(.+?)\s*\|\s*(.+?)\s*-->\s*$/g;
/**
* Generates the Deputy trace, used to determine who assessed a row.
*
* @return the Deputy trace
*/
function generateTrace() {
return `<!-- User:${mw.user.getName()}|${new Date().toISOString()} -->`;
}
/**
* Attempts to extract the Deputy trace from wikitext.
*
* @param wikitext
* @return The trace author and timestamp (if available), or null if a trace was not found.
*/
function guessTrace(wikitext) {
const traceExec = cloneRegex$1(traceRegex).exec(wikitext);
if (traceExec) {
return {
author: traceExec[1],
timestamp: new Date(traceExec[2])
};
}
else {
return null;
}
}
/**
* Displayed when a ContributionSurveyRow has no remaining diffs. Deputy is not able
* to perform the contribution survey itself, so there is no revision list.
*/
class DeputyFinishedContributionSurveyRow {
/**
* @param props Element properties
* @param props.row The reference row
* @param props.originalElement
* @return An HTML element
*/
constructor(props) {
this.props = props;
}
/**
* Checks if this row has a signature.
*
* @return `true` if this row's comment has a signature
*/
hasSignature() {
return this.author ? (this.timestamp ? true : 'maybe') : false;
}
/**
* Renders the element.
*
* @return The rendered row content
*/
render() {
var _a, _b;
const props = this.props;
const parser = window.deputy.session.rootSession.parser;
// Use DiscussionTools to identify the user and timestamp.
let parsedComment;
try {
parsedComment = (_b = (_a = parser.parse(props.originalElement)) === null || _a === void 0 ? void 0 : _a.commentItems) === null || _b === void 0 ? void 0 : _b[0];
}
catch (e) {
warn('Failed to parse user signature.', e);
}
if (!parsedComment) {
// See if the Deputy trace exists.
const fromTrace = guessTrace(props.row.wikitext);
if (fromTrace) {
this.author = fromTrace.author;
this.timestamp = fromTrace.timestamp && !isNaN(fromTrace.timestamp.getTime()) ?
window.moment(fromTrace.timestamp) :
undefined;
}
else {
// Fall back to guessing the author based on an in-house parser.
this.author = guessAuthor(props.row.comment);
// Don't even try to guess the timestamp.
}
}
else {
this.author = parsedComment.author;
this.timestamp = parsedComment.timestamp;
}
if (this.author) {
const userPage = new mw.Title(this.author, nsId('user'));
const talkPage = userPage.getTalkPage();
const contribsPage = new mw.Title('Special:Contributions/' + this.author);
const params = [
(h_1("span", null,
h_1("a", { target: "_blank", rel: "noopener", href: mw.util.getUrl(userPage.getPrefixedDb()), title: userPage.getPrefixedText() }, this.author),
" ",
h_1("span", { class: "mw-usertoollinks mw-changeslist-links" },
h_1("span", null,
h_1("a", { class: "mw-usertoollinks-talk", target: "_blank", rel: "noopener", href: mw.util.getUrl(talkPage.getPrefixedDb()), title: talkPage.getPrefixedText() }, mw.msg('deputy.revision.talk'))),
h_1("span", null,
h_1("a", { class: "mw-usertoollinks-contribs", target: "_blank", rel: "noopener", href: mw.util.getUrl(contribsPage.getPrefixedDb()), title: contribsPage.getPrefixedText() }, mw.msg('deputy.revision.contribs'))))))
];
if (this.timestamp) {
params.push(this.timestamp.toDate()
.toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' }), this.timestamp.toNow(true));
}
return unwrapJQ(h_1("i", null), mw.message(this.timestamp ?
'deputy.session.row.checkedComplete' :
'deputy.session.row.checked', ...params).parseDom());
}
else {
return null;
}
}
}
/**
*
*/
class DeputyCCIStatusDropdown extends EventTarget {
/**
* Create a new DeputyCCIStatusDropdown object.
*
* @param row The origin row of this dropdown.
* For the root session, this is simply the `row` field of the
* DeputyContributionSurveyRow that is handling the row.
* For dependent sessions, this is a much simpler version which includes
* only the case page info and the row title.
* @param row.casePage The DeputyCase for this dropdown
* @param row.title The title of the row (page) that this dropdown accesses
* @param options Additional construction options, usually used by the root session.
* @param row.type
*/
constructor(row, options = {}) {
var _a, _b;
super();
this.row = row;
this.options = new Map();
for (const status in ContributionSurveyRowStatus) {
if (isNaN(+status)) {
// String key, skip.
continue;
}
const statusName = ContributionSurveyRowStatus[status];
// The following classes are used here:
// * dp-cs-row-status--unfinished
// * dp-cs-row-status--unknown
// * dp-cs-row-status--withviolations
// * dp-cs-row-status--withoutviolations
// * dp-cs-row-status--missing
const option = new OO.ui.MenuOptionWidget({
classes: ['dp-cs-row-status--' + statusName.toLowerCase()],
data: +status,
label: mw.message('deputy.session.row.status.' +
statusName[0].toLowerCase() +
statusName.slice(1)).text(),
icon: DeputyCCIStatusDropdown.menuOptionIcons[+status],
// Always disable if Unknown, as Unknown is merely a placeholder value.
disabled: +status === ContributionSurveyRowStatus.Unknown
});
this.options.set(+status, option);
}
this.dropdown = new OO.ui.DropdownWidget(Object.assign({
classes: ['dp-cs-row-status'],
label: mw.msg('deputy.session.row.status')
}, (_a = options.widgetOptions) !== null && _a !== void 0 ? _a : {}, {
menu: {
items: Array.from(this.options.values())
}
}));
// Place before event listeners to prevent them from firing too early.
if (options.status != null) {
this.status = options.status;
}
if (options.enabled != null) {
this.setEnabledOptions(options.enabled);
}
const requireAcknowledge = (_b = options.requireAcknowledge) !== null && _b !== void 0 ? _b : true;
let pastStatus = this.status;
let processing = false;
let incommunicable = false;
this.dropdownChangeListener = () => __awaiter(this, void 0, void 0, function* () {
if (incommunicable) {
// Reset flag.
incommunicable = false;
return;
}
else if (processing) {
return;
}
processing = true;
this.dispatchEvent(Object.assign(new Event('change'), {
status: this.status
}));
this.refresh();
const message = yield window.deputy.comms[requireAcknowledge ? 'sendAndWait' : 'send']({
type: 'pageStatusUpdate',
caseId: this.row.casePage.pageId,
page: this.row.title.getPrefixedText(),
status: this.status
});
if (requireAcknowledge && message == null) {
// Broadcast failure as an event and restore to the past value.
// This will cause an infinite loop, so set `incommunicable` to true to
// avoid that.
this.dispatchEvent(Object.assign(new Event('updateFail'), {
data: {
former: pastStatus,
target: this.status
}
}));
incommunicable = true;
this.status = pastStatus;
}
else {
// Overwrite the past status.
pastStatus = this.status;
}
processing = false;
});
this.dropdownUpdateListener = (event) => {
var _a, _b;
const { data: message } = event;
if (message.caseId === this.row.casePage.pageId &&
message.page === this.row.title.getPrefixedText()) {
// Update the enabled and disabled options.
for (const enabled of (_a = message.enabledOptions) !== null && _a !== void 0 ? _a : []) {
this.setOptionDisabled(enabled, false, false);
}
for (const disabled of (_b = message.disabledOptions) !== null && _b !== void 0 ? _b : []) {
this.setOptionDisabled(disabled, true, false);
}
// Update the status.
this.status = message.status;
window.deputy.comms.reply(message, { type: 'acknowledge' });
}
};
window.deputy.comms.addEventListener('pageStatusUpdate', this.dropdownUpdateListener);
// Change the icon of the dropdown when the value changes.
this.dropdown.getMenu().on('select', this.dropdownChangeListener);
// Make the menu larger than the actual dropdown.
this.dropdown.getMenu().on('ready', () => {
this.dropdown.getMenu().toggleClipping(false);
unwrapWidget(this.dropdown.getMenu()).style.width = '20em';
});
}
/**
* @return The currently-selected status of this dropdown.
*/
get status() {
var _a, _b;
return (_b = (_a = this.dropdown.getMenu().findSelectedItem()) === null || _a === void 0 ? void 0 : _a.getData()) !== null && _b !== void 0 ? _b : null;
}
/**
* Sets the currently-selected status of this dropdown.
*/
set status(status) {
this.dropdown.getMenu().selectItemByData(status);
this.setOptionDisabled(ContributionSurveyRowStatus.Unknown, status !== ContributionSurveyRowStatus.Unknown, false);
this.refresh();
}
/**
* Performs cleanup
*/
close() {
window.deputy.comms.removeEventListener('pageStatusUpdate', this.dropdownUpdateListener);
}
/**
* @inheritDoc
*/
addEventListener(type, callback, options) {
super.addEventListener(type, callback, options);
}
/**
* Refreshes the status dropdown for any changes. This function must NOT
* modify `this.status`, or else it will cause a stack overflow.
*/
refresh() {
const icon = DeputyCCIStatusDropdown.menuOptionIcons[this.status];
this.dropdown.setIcon(icon);
}
/**
* Gets a list of enabled options.
*
* @return An array of {@link ContributionSurveyRowStatus}es
*/
getEnabledOptions() {
return Array.from(this.options.keys()).filter((status) => {
return !this.options.get(status).isDisabled();
});
}
/**
*
* @param enabledOptions
* @param broadcast
*/
setEnabledOptions(enabledOptions, broadcast = false) {
for (const status in ContributionSurveyRowStatus) {
const option = this.options.get(+status);
if (option == null) {
// Skip if null.
continue;
}
const toEnable = enabledOptions.indexOf(+status) !== -1;
const optionDisabled = option.isDisabled();
if (toEnable && optionDisabled) {
this.setOptionDisabled(+status, false, broadcast);
}
else if (!toEnable && !optionDisabled) {
this.setOptionDisabled(+status, true, broadcast);
}
}
}
/**
* Sets the disabled state of the dropdown. Does not affect menu options.
*
* @param disabled
*/
setDisabled(disabled) {
this.dropdown.setDisabled(disabled);
}
/**
* Sets the 'disable state' of specific menu options. For the `Unknown` option, a
* specific case is made which removes the option from the menu entirely.
*
* @param status
* @param disabled
* @param broadcast
*/
setOptionDisabled(status, disabled, broadcast = false) {
if (status === ContributionSurveyRowStatus.Unknown) {
// Special treatment. This hides the entire option from display.
this.options.get(status).toggle(disabled);
}
else {
// Disable the disable flag.
this.options.get(status).setDisabled(disabled);
}
if (this.status === status && disabled) {
this.selectNextBestValue(status);
}
if (broadcast) {
window.deputy.comms.send({
type: 'pageStatusUpdate',
caseId: this.row.casePage.pageId,
page: this.row.title.getPrefixedText(),
status: this.status,
[disabled ? 'disabledOptions' : 'enabledOptions']: [status]
});
}
}
/**
* When an option is about to be closed and the current status matches that option,
* this function will find the next best option and select it. The next best value
* is as follows:
*
* For
* - Unfinished: WithoutViolations, unless it's `pageonly`, on which it'll be kept as is.
* - Unknown: Unfinished
* - WithViolations: _usually not disabled, kept as is_
* - WithoutViolations: _usually not disabled, kept as is_
* - Missing: _usually not disabled, kept as is_
* - PresumptiveRemoval: _usually not disabled, kept as is_
*
* @param status The status that was <b>changed into</b>
*/
selectNextBestValue(status) {
if (status === ContributionSurveyRowStatus.Unfinished) {
if (this.row.type === 'pageonly') {
// Leave it alone.
return;
}
this.status = ContributionSurveyRowStatus.WithoutViolations;
}
else if (status === ContributionSurveyRowStatus.Unknown) {
this.status = ContributionSurveyRowStatus.Unfinished;
}
}
}
DeputyCCIStatusDropdown.menuOptionIcons = {
[ContributionSurveyRowStatus.Unfinished]: null,
[ContributionSurveyRowStatus.Unknown]: 'alert',
[ContributionSurveyRowStatus.WithViolations]: 'check',
[ContributionSurveyRowStatus.WithoutViolations]: 'close',
[ContributionSurveyRowStatus.Missing]: 'help',
[ContributionSurveyRowStatus.PresumptiveRemoval]: 'trash'
};
/**
* Shows a confirmation dialog, if the user does not have danger mode enabled.
* If the user has danger mode enabled, this immediately resolves to true, letting
* the action run immediately.
*
* Do not use this with any action that can potentially break templates, user data,
* or cause irreversible data loss.
*
* @param config The user's configuration
* @param message See {@link OO.ui.MessageDialog}'s parameters.
* @param options See {@link OO.ui.MessageDialog}'s parameters.
* @return Promise resolving to a true/false boolean.
*/
function dangerModeConfirm(config, message, options) {
if (config.all.core.dangerMode.get()) {
return $.Deferred().resolve(true);
}
else {
return OO.ui.confirm(message, options);
}
}
var DeputyContributionSurveyRowState;
(function (DeputyContributionSurveyRowState) {
/*
* Special boolean that gets set to true if the supposed data from `this.wikitext`
* should not be trusted. This is usually due to UI element failures or network
* issues that cause the revision list to be loaded improperly (or to be not
* loaded at all). `this.wikitext` will return the original wikitext, if capable.
*/
DeputyContributionSurveyRowState[DeputyContributionSurveyRowState["Broken"] = -1] = "Broken";
// Data not loaded, may be appended.
DeputyContributionSurveyRowState[DeputyContributionSurveyRowState["Loading"] = 0] = "Loading";
// Data loaded, ready for use.
DeputyContributionSurveyRowState[DeputyContributionSurveyRowState["Ready"] = 1] = "Ready";
// Closed by `close()`.
DeputyContributionSurveyRowState[DeputyContributionSurveyRowState["Closed"] = 2] = "Closed";
})(DeputyContributionSurveyRowState || (DeputyContributionSurveyRowState = {}));
/**
* A UI element used for denoting the following aspects of a page in the contribution
* survey:
* (a) the current status of the page (violations found, no violations found, unchecked, etc.)
* (b) the name of the page
* (c) special page tags
* (d) the number of edits within that specific row
* (e) the byte size of the largest-change diff
* (f) a list of revisions related to this page (as DeputyContributionSurveyRowRevision classes)
* (g) closing comments
*/
class DeputyContributionSurveyRow extends EventTarget {
/**
* Creates a new DeputyContributionSurveyRow object.
*
* @param row The contribution survey row data
* @param originalElement
* @param originalWikitext
* @param section The section that this row belongs to
*/
constructor(row, originalElement, originalWikitext, section) {
super();
/**
* The state of this element.
*/
this.state = DeputyContributionSurveyRowState.Loading;
/**
* Responder for session requests.
*/
this.statusRequestResponder = this.sendStatusResponse.bind(this);
this.nextRevisionRequestResponder = this.sendNextRevisionResponse.bind(this);
this.row = row;
this.originalElement = originalElement;
this.additionalComments = this.extractAdditionalComments();
this.originalWikitext = originalWikitext;
this.section = section;
}
/**
* @return `true` if:
* (a) this row's status changed OR
* (b) this row's comment changed
*
* This does not check if the revisions themselves were modified.
*/
get statusModified() {
return (this.status !== this.row.originalStatus ||
this.comments !== this.row.getActualComment());
}
/**
* @return `true` if:
* (a) `statusModified` is true OR
* (b) diffs were marked as completed
*
* This does not check if the revisions themselves were modified.
*/
get modified() {
var _a;
return this.statusModified ||
(
// This is assumed as a modification, since all diffs are automatically removed
// from the page whenever marked as complete. Therefore, there can never be a
// situation where a row's revisions have been modified but there are no completed
// revisions.
(_a = this.revisions) === null || _a === void 0 ? void 0 : _a.some((v) => v.completed));
}
/**
* @return The current status of this row.
*/
get status() {
return this.row.status;
}
/**
* Set the current status of this row.
*
* @param status The new status to apply
*/
set status(status) {
this.row.status = status;
}
/**
* @return `true` if this row has all diffs marked as completed.
*/
get completed() {
if (this.revisions == null) {
return true;
}
return this.revisions
.every((v) => v.completed);
}
/**
* @return `true` if this element is broken.
*/
get broken() {
return this.state === DeputyContributionSurveyRowState.Broken;
}
/**
* @return The comments for this row (as added by a user)
*/
get comments() {
var _a;
return (_a = this.commentsTextInput) === null || _a === void 0 ? void 0 : _a.getValue();
}
/**
* Generates a wikitext string representation of this row, preserving existing wikitext
* whenever possible.
*
* @return Wikitext
*/
get wikitext() {
var _a, _b, _c, _d;
// Broken, loading, or closed. Just return the original wikitext.
if (this.state !== DeputyContributionSurveyRowState.Ready) {
return this.originalWikitext;
}
if (this.wasFinished == null) {
warn('Could not determine if this is an originally-finished or ' +
'originally-unfinished row. Assuming unfinished and moving on...');
}
// "* "
let result = this.row.data.bullet;
if (this.row.data.creation) {
result += "'''N''' ";
}
// [[:Example]]
result += `[[${this.row.data.page}]]`;
// "{bullet}{creation}[[{page}]]{extras}{diffs}{comments}"
if (this.row.extras) {
result += `${this.row.extras}`;
}
const unfinishedDiffs = (_c = (_b = (_a = this.revisions) === null || _a === void 0 ? void 0 : _a.filter((v) => !v.completed)) === null || _b === void 0 ? void 0 : _b.sort((a, b) => ContributionSurveyRow.getSorterFunction(this.sortOrder)(a.revision, b.revision))) !== null && _c !== void 0 ? _c : [];
let diffsText = '';
if (unfinishedDiffs.length > 0) {
diffsText += unfinishedDiffs.map((v) => {
return mw.format(this.row.data.diffTemplate, v.revision.revid, v.revision.diffsize == null ?
// For whatever reason, diffsize is missing. Fall back to the text we had
// previously.
v.uiRow.row.data.revidText[v.revision.revid] :
(v.revision.diffsize > 0 ?
'+' + v.revision.diffsize : v.revision.diffsize));
}).join('');
result += mw.format(this.row.data.diffsTemplate, diffsText);
if (this.row.data.comments) {
// Comments existed despite not being finished yet. Allow anyway.
result += this.row.data.comments;
}
}
else {
/**
* Function will apply the current user values to the row.
*/
const useUserData = () => {
let addComments = false;
switch (this.status) {
// TODO: l10n
case ContributionSurveyRowStatus.Unfinished:
// This state should not exist. Just add signature (done outside of switch).
break;
case ContributionSurveyRowStatus.Unknown:
// This state should not exist. Try to append comments (because if this
// branch is running, the comment must have not been added by the positive
// branch of this if statement). Don't append user-provided comments.
result += this.row.comment;
break;
case ContributionSurveyRowStatus.WithViolations:
result += '{{y}}';
addComments = true;
break;
case ContributionSurveyRowStatus.WithoutViolations:
result += '{{n}}';
addComments = true;
break;
case ContributionSurveyRowStatus.Missing:
result += '{{?}}';
addComments = true;
break;
case ContributionSurveyRowStatus.PresumptiveRemoval:
result += '{{x}}';
addComments = true;
break;
}
const userComments = this.comments
.replace(/~~~~\s*$/g, '')
.trim();
if (addComments && userComments.length > 0) {
result += ' ' + userComments;
}
// Sign.
result += ' ~~~~';
};
if (this.statusModified) {
// Modified. Use user data.
useUserData();
}
else if ((_d = this.wasFinished) !== null && _d !== void 0 ? _d : false) {
// No changes. Just append original closure comments.
result += this.row.comment;
}
// Otherwise, leave this row unchanged.
}
return result;
}
/**
* @return The hash used for autosave keys
*/
get autosaveHash() {
return `CASE--${this.row.casePage.title.getPrefixedDb()}+PAGE--${this.row.title.getPrefixedDb()}`;
}
/**
* Extracts HTML elements which may be additional comments left by others.
* The general qualification for this is that it has to be a list block
* element that comes after the main line (in this case, it's detected after
* the last .
* This appears in the following form in wikitext:
*
* ```
* * [[Page]] (...) [[Special:Diff/...|...]]
* *: Hello! <-- definition list block
* ** What!? <-- sub ul
* *# Yes. <-- sub ol
* * [[Page]] (...) [[Special:Diff/...|...]]<div>...</div> <-- inline div
* ```
*
* Everything else (`*<div>...`, `*'''...`, `*<span>`, etc.) is considered
* not to be an additional comment.
*
* If no elements were found, this returns an empty array.
*
* @return An array of HTMLElements
*/
extractAdditionalComments() {
// COMPAT: Specific to MER-C contribution surveyor
// Initialize to first successive diff link.
let lastSuccessiveDiffLink = this.originalElement.querySelector('a[href^="/wiki/Special:Diff/"]');
const elements = [];
if (!lastSuccessiveDiffLink) {
// No diff links. Get last element, check if block element, and crawl backwards.
let nextDiscussionElement = this.originalElement.lastElementChild;
while (nextDiscussionElement &&
window.getComputedStyle(nextDiscussionElement, '').display === 'block') {
elements.push(nextDiscussionElement);
nextDiscussionElement = nextDiscussionElement.previousElementSibling;
}
}
else {
while (lastSuccessiveDiffLink.nextElementSibling &&
lastSuccessiveDiffLink.nextElementSibling.tagName === 'A' &&
lastSuccessiveDiffLink
.nextElementSibling
.getAttribute('href')
.startsWith('/wiki/Special:Diff')) {
lastSuccessiveDiffLink = lastSuccessiveDiffLink.nextElementSibling;
}
// The first block element after `lastSuccessiveDiffLink` is likely discussion,
// and everything after it is likely part of such discussion.
let pushing = false;
let nextDiscussionElement = lastSuccessiveDiffLink.nextElementSibling;
while (nextDiscussionElement != null) {
if (!pushing &&
window.getComputedStyle(nextDiscussionElement).display === 'block') {
pushing = true;
elements.push(nextDiscussionElement);
}
else if (pushing) {
elements.push(nextDiscussionElement);
}
nextDiscussionElement = nextDiscussionElement.nextElementSibling;
}
}
return elements;
}
/**
* Load the revision data in and change the UI element respectively.
*/
loadData() {
return __awaiter(this, void 0, void 0, function* () {
try {
const diffs = yield this.row.getDiffs();
this.sortOrder = ContributionSurveyRow.guessSortOrder(diffs.values());
this.wasFinished = this.row.completed;
if (this.row.completed) {
this.renderRow(diffs, this.renderFinished());
}
else {
this.renderRow(diffs, yield this.renderUnfinished(diffs));
const savedStatus = yield this.getSavedStatus();
if (!this.wasFinished && savedStatus) {
// An autosaved status exists. Let's use that.
this.commentsTextInput.setValue(savedStatus.comments);
this.statusDropdown.status = savedStatus.status;
this.onUpdate();
}
}
window.deputy.comms.addEventListener('pageStatusRequest', this.statusRequestResponder);
window.deputy.comms.addEventListener('pageNextRevisionRequest', this.nextRevisionRequestResponder);
this.state = DeputyContributionSurveyRowState.Ready;
}
catch (e) {
error('Caught exception while loading data', e);
this.state = DeputyContributionSurveyRowState.Broken;
this.renderRow(null, unwrapWidget(new OO.ui.MessageWidget({
type: 'error',
label: mw.msg('deputy.session.row.error', e.message)
})));
this.setDisabled(true);
}
});
}
/**
* Perform UI updates and recheck possible values.
*/
onUpdate() {
if (this.statusAutosaveFunction == null) {
// TODO: types-mediawiki limitation
this.statusAutosaveFunction = mw.util.throttle(() => __awaiter(this, void 0, void 0, function* () {
yield this.saveStatus();
}), 500);
}
if (this.revisions && this.statusDropdown) {
if (this.row.type !== 'pageonly') {
// Only disable this option if the row isn't already finished.
this.statusDropdown.setOptionDisabled(ContributionSurveyRowStatus.Unfinished, this.completed, true);
}
const unfinishedWithStatus = this.statusModified && !this.completed;
if (this.unfinishedMessageBox) {
this.unfinishedMessageBox.toggle(
// If using danger mode, this should always be enabled.
!window.deputy.config.core.dangerMode.get() &&
unfinishedWithStatus);
}
this.statusAutosaveFunction();
}
if (this.wasFinished && this.statusModified && this.commentsField && this.finishedRow) {
this.commentsField.setNotices({
true: [mw.msg('deputy.session.row.close.sigFound')],
maybe: [mw.msg('deputy.session.row.close.sigFound.maybe')],
false: []
}[`${this.finishedRow.hasSignature()}`]);
}
else if (this.commentsField) {
this.commentsField.setNotices([]);
}
// Emit "update" event
this.dispatchEvent(new CustomEvent('update'));
}
/**
* Gets the database-saved status. Used for getting the autosaved values of the status and
* closing comments.
*/
getSavedStatus() {
return __awaiter(this, void 0, void 0, function* () {
return yield window.deputy.storage.db.get('pageStatus', this.autosaveHash);
});
}
/**
* Save the status and comment for this row to DeputyStorage.
*/
saveStatus() {
return __awaiter(this, void 0, void 0, function* () {
if (this.statusModified) {
yield window.deputy.storage.db.put('pageStatus', {
hash: this.autosaveHash,
status: this.status,
comments: this.comments
});
}
});
}
/**
* Mark all revisions of this section as finished.
*/
markAllAsFinished() {
if (!this.revisions) {
// If `renderUnfinished` was never called, this will be undefined.
// We want to skip over instead.
return;
}
this.revisions.forEach((revision) => {
revision.completed = true;
});
this.onUpdate();
}
/**
* Renders the `commentsTextInput` variable (closing comments OOUI TextInputWidget)
*
* @param value
* @return The OOUI TextInputWidget
*/
renderCommentsTextInput(value) {
this.commentsTextInput = new OO.ui.MultilineTextInputWidget({
classes: ['dp-cs-row-closeComments'],
placeholder: mw.msg('deputy.session.row.closeComments'),
value: value !== null && value !== void 0 ? value : '',
autosize: true,
rows: 1
});
this.commentsTextInput.on('change', () => {
this.onUpdate();
});
return this.commentsTextInput;
}
/**
* Render the row with the "finished" state (has info
* on closer and closing comments).
*
* @return HTML element
*/
renderFinished() {
this.finishedRow = new DeputyFinishedContributionSurveyRow({
originalElement: this.originalElement,
row: this.row
});
return h_1("div", { class: "dp-cs-row-finished" },
this.finishedRow.render(),
unwrapWidget(this.commentsField = new OO.ui.FieldLayout(this.renderCommentsTextInput(this.row.getActualComment()), {
align: 'top',
invisibleLabel: true,
label: mw.msg('deputy.session.row.closeComments')
})));
}
/**
* Render the row with the "unfinished" state (has
* revision list, etc.)
*
* @param diffs
* @return HTML element
*/
renderUnfinished(diffs) {
return __awaiter(this, void 0, void 0, function* () {
this.revisions = [];
const revisionList = document.createElement('div');
revisionList.classList.add('dp-cs-row-revisions');
this.unfinishedMessageBox = new OO.ui.MessageWidget({
classes: ['dp-cs-row-unfinishedWarning'],
type: 'warning',
label: mw.msg('deputy.session.row.unfinishedWarning')
});
this.unfinishedMessageBox.toggle(false);
revisionList.appendChild(unwrapWidget(this.unfinishedMessageBox));
revisionList.appendChild(unwrapWidget(this.renderCommentsTextInput(this.row.comment)));
if (this.row.type === 'pageonly') {
revisionList.appendChild(h_1("div", { class: "dp-cs-row-pageonly" },
h_1("i", null, mw.msg('deputy.session.row.pageonly'))));
}
else {
const cciConfig = window.deputy.config.cci;
const maxSize = cciConfig.maxSizeToAutoShowDiff.get();
for (const revision of diffs.values()) {
const revisionUIEl = new DeputyContributionSurveyRevision(revision, this, {
expanded: cciConfig.autoShowDiff.get() &&
diffs.size < cciConfig.maxRevisionsToAutoShowDiff.get() &&
(maxSize === -1 || Math.abs(revision.diffsize) < maxSize)
});
revisionUIEl.addEventListener('update', () => {
// Recheck options first to avoid "Unfinished" being selected when done.
this.onUpdate();
});
yield revisionUIEl.prepare();
revisionList.appendChild(revisionUIEl.render());
this.revisions.push(revisionUIEl);
}
}
return revisionList;
});
}
/**
* Renders action button links.
*
* @return An HTML element
*/
renderLinks() {
return h_1("span", { class: "dp-cs-row-links" },
h_1("a", { class: "dp-cs-row-link dp-cs-row-edit", target: "_blank", rel: "noopener", href: mw.util.getUrl(this.row.title.getPrefixedDb(), { action: 'edit' }) }, unwrapWidget(new OO.ui.ButtonWidget({
invisibleLabel: true,
label: mw.msg('deputy.session.row.edit'),
title: mw.msg('deputy.session.row.edit'),
icon: 'edit',
framed: false
}))),
h_1("a", { class: "dp-cs-row-link dp-cs-row-talk", target: "_blank", rel: "noopener", href: mw.util.getUrl(this.row.title.getTalkPage().getPrefixedDb()) }, unwrapWidget(new OO.ui.ButtonWidget({
invisibleLabel: true,
label: mw.msg('deputy.session.row.talk'),
title: mw.msg('deputy.session.row.talk'),
icon: 'speechBubbles',
framed: false
}))),
h_1("a", { class: "dp-cs-row-link dp-cs-row-history", target: "_blank", rel: "noopener", href: mw.util.getUrl(this.row.title.getPrefixedDb(), { action: 'history' }) }, unwrapWidget(new OO.ui.ButtonWidget({
invisibleLabel: true,
label: mw.msg('deputy.session.row.history'),
title: mw.msg('deputy.session.row.history'),
icon: 'history',
framed: false
}))));
}
/**
* Renders the details of the row. Includes details such as largest diff size, diffs
* remaining, etc.
*
* @param diffs
* @return The row details as an element (or `false`, if no details are to be shown).
*/
renderDetails(diffs) {
const parts = [];
// Timestamp is always found in a non-missing diff, suppressed or not.
const validDiffs = Array.from(diffs.values()).filter((v) => v.timestamp);
if (validDiffs.length > 0) {
const diffArray = Array.from(diffs.values());
if (diffArray.some((v) => !v.parentid)) {
parts.push(mw.message('deputy.session.row.details.new', diffs.size.toString()).text());
}
// Number of edits
parts.push(mw.message('deputy.session.row.details.edits', diffs.size.toString()).text());
// Identify largest diff
const largestDiff = diffs.get(Array.from(diffs.values())
.sort(ContributionSurveyRow.getSorterFunction(ContributionSurveyRowSort.Bytes))[0]
.revid);
parts.push(
// Messages that can be used here:
// * deputy.negativeDiff
// * deputy.positiveDiff
// * deputy.zeroDiff
mw.message(`deputy.${{
'-1': 'negative',
1: 'positive',
0: 'zero'
}[Math.sign(largestDiff.diffsize)]}Diff`, largestDiff.diffsize.toString()).text());
}
const spliced = [];
for (let index = 0; index < parts.length; index++) {
spliced.push(h_1("span", { class: "dp-cs-row-detail" }, parts[index]));
if (index !== parts.length - 1) {
spliced.push(mw.msg('comma-separator'));
}
}
return parts.length === 0 ? false : h_1("span", { class: "dp-cs-row-details" },
"(",
spliced,
")");
}
/**
* Renders the "head" part of the row. Contains the status, page name, and details.
*
* @param diffs
* @param contentContainer
* @return The head of the row as an element
*/
renderHead(diffs, contentContainer) {
const possibleStatus = this.row.status;
// Build status dropdown
this.statusDropdown = new DeputyCCIStatusDropdown(this.row, {
status: possibleStatus,
requireAcknowledge: false
});
if (this.row.type !== 'pageonly' &&
((diffs && diffs.size === 0) || this.wasFinished)) {
// If there are no diffs found or `this.wasFinished` is set (both meaning there are
// no diffs and this is an already-assessed row), then the "Unfinished" option will
// be disabled. This does not apply for page-only rows, which never have diffs.
this.statusDropdown.setOptionDisabled(ContributionSurveyRowStatus.Unfinished, true);
}
this.statusDropdown.addEventListener('change', (event) => {
this.status = event.status;
this.onUpdate();
});
// Build mass checker
this.checkAllButton = new OO.ui.ButtonWidget({
icon: 'checkAll',
label: mw.msg('deputy.session.row.checkAll'),
title: mw.msg('deputy.session.row.checkAll'),
invisibleLabel: true,
framed: false
});
this.checkAllButton.on('click', () => {
dangerModeConfirm(window.deputy.config, mw.msg('deputy.session.row.checkAll.confirm')).done((confirmed) => {
if (confirmed) {
this.markAllAsFinished();
}
});
});
// Build content toggler
const contentToggle = new OO.ui.ButtonWidget({
classes: ['dp-cs-row-toggle'],
// Will be set by toggle function. Blank for now.
label: '',
invisibleLabel: true,
framed: false
});
let contentToggled = !window.deputy.config.cci.autoCollapseRows.get();
/**
* Toggles the content.
*
* @param show Whether to show the content or not.
*/
const toggleContent = (show = !contentToggled) => {
contentToggle.setIcon(show ? 'collapse' : 'expand');
contentToggle.setLabel(mw.message(show ?
'deputy.session.row.content.close' :
'deputy.session.row.content.open').text());
contentToggle.setTitle(mw.message(show ?
'deputy.session.row.content.close' :
'deputy.session.row.content.open').text());
contentContainer.style.display = show ? 'block' : 'none';
contentToggled = show;
};
toggleContent(contentToggled);
contentToggle.on('click', () => {
toggleContent();
});
return h_1("div", { class: "dp-cs-row-head" },
unwrapWidget(this.statusDropdown.dropdown),
h_1("a", { class: "dp-cs-row-title", target: "_blank", rel: "noopener", href: mw.format(mw.config.get('wgArticlePath'), this.row.title.getPrefixedDb()) }, this.row.title.getPrefixedText()),
diffs && this.renderDetails(diffs),
this.renderLinks(),
!this.wasFinished && diffs && diffs.size > 0 && unwrapWidget(this.checkAllButton),
!contentContainer.classList.contains('dp-cs-row-content-empty') &&
unwrapWidget(contentToggle));
}
/**
* Renders additional comments that became part of this row.
*
* @return An HTML element.
*/
renderAdditionalComments() {
const additionalComments = h_1("div", { class: "dp-cs-row-comments" },
h_1("b", null, mw.msg('deputy.session.row.additionalComments')),
h_1("hr", null),
h_1("div", { class: "dp-cs-row-comments-content", dangerouslySetInnerHTML: this.additionalComments.map(e => e.innerHTML).join('') }));
// Open all links in new tabs.
additionalComments.querySelectorAll('.dp-cs-row-comments-content a')
.forEach(a => a.setAttribute('target', '_blank'));
return additionalComments;
}
/**
*
* @param diffs
* @param content
*/
renderRow(diffs, content) {
var _a;
const contentContainer = h_1("div", { class: classMix([
'dp-cs-row-content',
!content && 'dp-cs-row-content-empty'
]) }, content);
this.element = swapElements(this.element, h_1("div", null,
this.renderHead(diffs, contentContainer),
((_a = this.additionalComments) === null || _a === void 0 ? void 0 : _a.length) > 0 && this.renderAdditionalComments(),
contentContainer));
}
/**
* @inheritDoc
*/
render() {
this.element = h_1(DeputyLoadingDots, null);
this.rootElement = h_1("div", { class: "dp-cs-row" }, this.element);
return this.rootElement;
}
/**
* Performs cleanup before removal.
*/
close() {
var _a;
this.state = DeputyContributionSurveyRowState.Closed;
window.deputy.comms.removeEventListener('pageStatusRequest', this.statusRequestResponder);
window.deputy.comms.removeEventListener('pageNextRevisionRequest', this.nextRevisionRequestResponder);
(_a = this.revisions) === null || _a === void 0 ? void 0 : _a.forEach((revision) => {
revision.close();
});
}
/**
* Sets the disabled state of this section.
*
* @param disabled
*/
setDisabled(disabled) {
var _a, _b, _c, _d;
(_a = this.statusDropdown) === null || _a === void 0 ? void 0 : _a.setDisabled(disabled);
(_b = this.commentsTextInput) === null || _b === void 0 ? void 0 : _b.setDisabled(disabled);
(_c = this.checkAllButton) === null || _c === void 0 ? void 0 : _c.setDisabled(disabled);
(_d = this.revisions) === null || _d === void 0 ? void 0 : _d.forEach((revision) => revision.setDisabled(disabled));
this.disabled = disabled;
}
/**
* Responds to a status request.
*
* @param event
*/
sendStatusResponse(event) {
var _a, _b, _c, _d;
const rev = (_a = this.revisions) === null || _a === void 0 ? void 0 : _a.find((r) => r.revision.revid === event.data.revision);
// Handles the cases:
// * Page title and revision ID (if supplied) match
// * Page title matches
// * Page revision ID (if supplied) matches
if (event.data.page === this.row.title.getPrefixedText() ||
(rev && event.data.revision)) {
window.deputy.comms.reply(event.data, {
type: 'pageStatusResponse',
caseId: this.row.casePage.pageId,
caseTitle: this.row.casePage.title.getPrefixedText(),
title: this.row.title.getPrefixedText(),
status: this.status,
enabledStatuses: this.statusDropdown.getEnabledOptions(),
rowType: this.row.type,
revisionStatus: rev ? rev.completed : undefined,
revision: event.data.revision,
nextRevision: (_d = (_c = (_b = this.revisions) === null || _b === void 0 ? void 0 : _b.find((revision) => !revision.completed &&
revision.revision.revid !== event.data.revision)) === null || _c === void 0 ? void 0 : _c.revision.revid) !== null && _d !== void 0 ? _d : null
});
}
}
/**
*
* @param event
*/
sendNextRevisionResponse(event) {
var _a, _b, _c;
if (event.data.caseId === this.row.casePage.pageId &&
event.data.page === this.row.title.getPrefixedText()) {
if (!this.revisions) {
window.deputy.comms.reply(event.data, {
type: 'pageNextRevisionResponse',
revid: null
});
}
else {
// If `event.data.after` == null, this will be `undefined`.
const baseRevision = this.revisions
.find((r) => r.revision.revid === event.data.after);
const baseRevisionIndex = baseRevision == null ?
0 : this.revisions.indexOf(baseRevision);
// Find the next revision that is not completed.
const exactRevision = event.data.reverse ?
last(this.revisions.filter((r, i) => i < baseRevisionIndex && !r.completed)) :
this.revisions.find((r, i) => i > baseRevisionIndex && !r.completed);
const firstRevision = exactRevision == null ?
this.revisions.find((r) => !r.completed) : null;
// Returns `null` if an `exactRevision` or a `firstRevision` were not found.
window.deputy.comms.reply(event.data, {
type: 'pageNextRevisionResponse',
revid: (_c = (_b = (_a = (exactRevision !== null && exactRevision !== void 0 ? exactRevision : firstRevision)) === null || _a === void 0 ? void 0 : _a.revision) === null || _b === void 0 ? void 0 : _b.revid) !== null && _c !== void 0 ? _c : null
});
}
}
}
}
DeputyContributionSurveyRow.menuOptionIcon = {
[ContributionSurveyRowStatus.Unfinished]: false,
[ContributionSurveyRowStatus.Unknown]: 'alert',
[ContributionSurveyRowStatus.WithViolations]: 'check',
[ContributionSurveyRowStatus.WithoutViolations]: 'close',
[ContributionSurveyRowStatus.Missing]: 'help',
[ContributionSurveyRowStatus.PresumptiveRemoval]: 'trash'
};
/**
* Represents a ContributionSurveySection. Contains a list of {@link ContributionSurveyRow}s
* that make up a section generated by the contribution survey.
*/
class ContributionSurveySection {
/**
* @param casePage The case page of this section
* @param name The name of this section (based on the heading)
* @param closed Whether this section has been closed (wrapped in collapse templates)
* @param closingComments Closing comments for this section
* @param wikitext The original wikitext of this section
* @param revid The revision ID of the wikitext attached to this section.
*/
constructor(casePage, name, closed, closingComments, wikitext, revid) {
this.casePage = casePage;
this.name = name;
this.closed = closed;
this.closingComments = closingComments;
this.originalWikitext = wikitext;
this.originallyClosed = closed;
this.revid = revid;
}
}
let InternalDeputyReviewDialog;
/**
* Initializes the process dialog.
*/
function initDeputyReviewDialog() {
var _a;
InternalDeputyReviewDialog = (_a = class DeputyReviewDialog extends OO.ui.ProcessDialog {
/**
*
* @param config
*/
constructor(config) {
config.size = config.size || 'larger';
super(config);
this.data = config;
}
/**
* @return The body height of this dialog.
*/
getBodyHeight() {
return 500;
}
/**
*
* @param {...any} args
*/
initialize(...args) {
super.initialize.apply(this, args);
this.element = h_1("div", { style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center'
} },
h_1("div", { style: { marginBottom: '8px' } }, mw.msg('deputy.diff.load')),
unwrapWidget(new OO.ui.ProgressBarWidget({
classes: ['dp-review-progress'],
progress: false
})));
this.content = new OO.ui.PanelLayout({ expanded: true, padded: true });
unwrapWidget(this.content).appendChild(this.element);
this.$body.append(this.content.$element);
return this;
}
/**
* @param data
* @return The ready process for this object.
*/
getReadyProcess(data) {
return super.getReadyProcess.call(this, data)
.next(new Promise((res) => {
// Load MediaWiki diff styles
mw.loader.using('mediawiki.diff.styles', () => res());
}))
.next(() => __awaiter(this, void 0, void 0, function* () {
// Load diff HTML
const compareRequest = yield MwApi.action.post({
action: 'compare',
fromtitle: this.data.title.getPrefixedText(),
fromslots: 'main',
totitle: this.data.title.getPrefixedText(),
toslots: 'main',
topst: 1,
prop: 'diff',
slots: 'main',
'fromtext-main': this.data.from,
'fromcontentformat-main': 'text/x-wiki',
'fromcontentmodel-main': 'wikitext',
'totext-main': this.data.to,
'tocontentformat-main': 'text/x-wiki',
'tocontentmodel-main': 'wikitext'
});
if (compareRequest.error) {
swapElements(this.element, unwrapWidget(new OO.ui.MessageWidget({
type: 'error',
label: mw.msg('deputy.diff.error')
})));
}
const diffHTML = compareRequest.compare.bodies.main;
if (!diffHTML) {
this.element = swapElements(this.element, h_1("div", { style: { textAlign: 'center' } }, mw.msg('deputy.diff.no-changes')));
}
else {
// noinspection JSXDomNesting
this.element = swapElements(this.element, h_1("table", { class: "diff" },
h_1("colgroup", null,
h_1("col", { class: "diff-marker" }),
h_1("col", { class: "diff-content" }),
h_1("col", { class: "diff-marker" }),
h_1("col", { class: "diff-content" })),
h_1("tbody", { dangerouslySetInnerHTML: diffHTML })));
}
}), this);
}
/**
* @param action
* @return The action process
*/
getActionProcess(action) {
if (action === 'close') {
return new OO.ui.Process(function () {
this.close({
action: action
});
}, this);
}
// Fallback to parent handler
return super.getActionProcess.call(this, action);
}
},
_a.static = Object.assign(Object.assign({}, OO.ui.ProcessDialog.static), { name: 'deputyReviewDialog', title: mw.msg('deputy.diff'), actions: [
{
flags: ['safe', 'close'],
icon: 'close',
label: mw.msg('deputy.ante.close'),
title: mw.msg('deputy.ante.close'),
invisibleLabel: true,
action: 'close'
}
] }),
_a);
}
/**
* Creates a new DeputyReviewDialog.
*
* @param config
* @return A DeputyReviewDialog
*/
function DeputyReviewDialog (config) {
if (!InternalDeputyReviewDialog) {
initDeputyReviewDialog();
}
return new InternalDeputyReviewDialog(config);
}
/**
* Get the ID of a section from its heading.
*
* @param page The page to check for
* @param sectionName The section name to get the ID of
* @param n The `n`th occurrence of a section with the same name
*/
function getSectionId (page, sectionName, n = 1) {
return __awaiter(this, void 0, void 0, function* () {
const parseRequest = yield MwApi.action.get({
action: 'parse',
page: normalizeTitle(page).getPrefixedText(),
prop: 'sections'
});
if (parseRequest.error) {
throw new Error('Error finding section ID: ' + parseRequest.error.info);
}
let indexSection;
let currentN = 1;
for (const section of parseRequest.parse.sections) {
if (section.line === sectionName) {
if (currentN < n) {
currentN++;
}
else {
indexSection = section;
break;
}
}
}
if (indexSection) {
return isNaN(+indexSection.index) ? null : +indexSection.index;
}
else {
return null;
}
});
}
/**
* Get the parser output HTML of a specific page section.
*
* @param page
* @param section
* @param extraOptions
* @return A promise resolving to the `<div class="mw-parser-output">` element.
*/
function getSectionHTML (page, section, extraOptions = {}) {
return __awaiter(this, void 0, void 0, function* () {
if (typeof section === 'string') {
section = yield getSectionId(page, section);
}
return MwApi.action.get(Object.assign({ action: 'parse', prop: 'text|wikitext|revid', page: normalizeTitle(page).getPrefixedText(), section: section, disablelimitreport: true }, extraOptions)).then((data) => {
const temp = document.createElement('span');
temp.innerHTML = data.parse.text;
return {
element: temp.children[0],
wikitext: data.parse.wikitext,
revid: data.parse.revid
};
});
});
}
/**
* Appends extra information to an edit summary (also known as the "advert").
*
* @param editSummary The edit summary
* @param config The user's configuration. Used to get the "danger mode" setting.
* @return The decorated edit summary (in wikitext)
*/
function decorateEditSummary (editSummary, config) {
var _a;
const dangerMode = (_a = config === null || config === void 0 ? void 0 : config.core.dangerMode.get()) !== null && _a !== void 0 ? _a : false;
return `${editSummary} ([[Wikipedia:Deputy|Deputy]] v${version}${dangerMode ? '!' : ''})`;
}
/**
* Checks the n of a given element, that is to say the `n`th occurrence of a section
* with this exact heading name in the entire page.
*
* This is purely string- and element-based, with no additional metadata or parsing
* information required.
*
* This function detects the `n` using the following conditions:
* - If the heading ID does not have an n suffix, the n is always 1.
* - If the heading ID does have an n suffix, and the detected heading name does not end
* with a number, the n is always the last number on the ID.
* - If the heading ID and heading name both end with a number,
* - The n is 1 if the ID has an equal number of ending number patterns (sequences of "_n",
* e.g. "_20_30_40" has three) with the heading name.
* - Otherwise, the n is the last number on the ID if the ID than the heading name.
*
* @param heading The heading to check
* @return The n, a number
*/
function sectionHeadingN(heading) {
try {
const headingNameEndPattern = /(?:\s|_)(\d+)/g;
const headingIdEndPattern = /_(\d+)/g;
const headingId = heading.id;
const headingIdMatches = headingId.match(headingIdEndPattern);
const headingNameMatches = heading.title.match(headingNameEndPattern);
if (headingIdMatches == null) {
return 1;
}
else if (headingNameMatches == null) {
// Last number of the ID
return +(headingIdEndPattern.exec(last(headingIdMatches))[1]);
}
else if (headingIdMatches.length === headingNameMatches.length) {
return 1;
}
else {
// Last number of the ID
return +(headingIdEndPattern.exec(last(headingIdMatches))[1]);
}
}
catch (e) {
error('Error getting section number', e, heading);
throw e;
}
}
/**
* Wraps a set of nodes in a div.dp-cs-extraneous element.
*
* @param children The nodes to wrap
*/
function DeputyExtraneousElement(children) {
const container = document.createElement('div');
container.classList.add('dp-cs-extraneous');
children = Array.isArray(children) ? children : [children];
children.forEach(child => container.appendChild(child.cloneNode(true)));
return container;
}
/**
* What it says on the tin. Attempt to parse out a `title`, `diff`,
* or `oldid` from a URL. This is useful for converting diff URLs into actual
* diff information, and especially useful for {{copied}} templates.
*
* If diff parameters were not found (no `diff` or `oldid`), they will be `null`.
*
* @param url The URL to parse
* @return Parsed info: `diff` or `oldid` revision IDs, and/or the page title.
*/
function parseDiffUrl(url) {
if (typeof url === 'string') {
url = new URL(url);
}
// Attempt to get values from URL parameters (when using `/w/index.php?action=diff`)
let oldid = url.searchParams.get('oldid');
let diff = url.searchParams.get('diff');
let title = url.searchParams.get('title');
// Attempt to get information from this URL.
tryConvert: {
if (title && oldid && diff) {
// Skip if there's nothing else we need to get.
break tryConvert;
}
// Attempt to get values from Special:Diff short-link
const diffSpecialPageCheck =
// eslint-disable-next-line security/detect-unsafe-regex
/\/wiki\/Special:Diff\/(prev|next|\d+)(?:\/(prev|next|\d+))?/i.exec(url.pathname);
if (diffSpecialPageCheck != null) {
if (diffSpecialPageCheck[1] != null &&
diffSpecialPageCheck[2] == null) {
// Special:Diff/diff
diff = diffSpecialPageCheck[1];
}
else if (diffSpecialPageCheck[1] != null &&
diffSpecialPageCheck[2] != null) {
// Special:Diff/oldid/diff
oldid = diffSpecialPageCheck[1];
diff = diffSpecialPageCheck[2];
}
break tryConvert;
}
// Attempt to get values from Special:PermanentLink short-link
const permanentLinkCheck = /\/wiki\/Special:Perma(nent)?link\/(\d+)/i.exec(url.pathname);
if (permanentLinkCheck != null) {
oldid = permanentLinkCheck[2];
break tryConvert;
}
// Attempt to get values from article path with ?oldid or ?diff
// eslint-disable-next-line security/detect-non-literal-regexp
const articlePathRegex = new RegExp(mw.util.getUrl('(.*)'))
.exec(url.pathname);
if (articlePathRegex != null) {
title = decodeURIComponent(articlePathRegex[1]);
break tryConvert;
}
}
// Convert numbers to numbers
if (oldid != null && !isNaN(+oldid)) {
oldid = +oldid;
}
if (diff != null && !isNaN(+diff)) {
diff = +diff;
}
// Try to convert a page title
try {
title = new mw.Title(title).getPrefixedText();
}
catch (e) {
warn('Failed to normalize page title during diff URL conversion.');
}
return {
diff: diff,
oldid: oldid,
title: title
};
}
/**
* The contribution survey section UI element. This includes a list of revisions
* (which are {@link DeputyContributionSurveyRow} objects), a "close section"
* checkbox, a "comments" input box (for additional comments when closing the
* section), a "cancel" button and a "save" button.
*/
class DeputyContributionSurveySection {
/**
* Creates a DeputyContributionSurveySection from a given heading.
*
* @param casePage
* @param heading
*/
constructor(casePage, heading) {
this.casePage = casePage;
this.heading = normalizeWikiHeading(heading);
this.sectionNodes = casePage.getContributionSurveySection(heading);
}
/**
* @return `true` if this section has been modified
*/
get modified() {
return this.rows && this.rows.length > 0 &&
this.rows.some((row) => row.modified) || (this._section && this._section.originallyClosed !== this.closed);
}
/**
* @return `true` if this section is (or will be) closed
*/
get closed() {
var _a;
return (_a = this._section) === null || _a === void 0 ? void 0 : _a.closed;
}
/**
* Sets the close state of this section
*/
set closed(value) {
var _a;
if (((_a = this._section) === null || _a === void 0 ? void 0 : _a.closed) == null) {
throw new Error('Section has not been loaded yet.');
}
this._section.closed = value;
}
/**
* @return The closing comments for this section
*/
get comments() {
var _a;
return (_a = this._section) === null || _a === void 0 ? void 0 : _a.closingComments;
}
/**
* Sets the comments of a section.
*/
set comments(value) {
if (this._section == null) {
throw new Error('Section has not been loaded yet.');
}
this._section.closingComments = value;
}
/**
* @return The wikitext for this section.
*/
get wikitext() {
var _a;
let final = [];
for (const obj of this.wikitextLines) {
if (typeof obj === 'string') {
final.push(obj);
}
else {
final.push(obj.wikitext);
}
}
let lastModifiedRowIndex;
for (const i in final) {
const wikitext = final[+i];
if (wikitext.indexOf(' ~~~~') !== -1) {
lastModifiedRowIndex = +i;
}
}
const trace = ` ${generateTrace()}`;
if (lastModifiedRowIndex != null) {
// If `lastModifiedRowIndex` exists, we can assume that a modified row exists.
// This prevents the following from running on unmodified rows, which is
// wasteful.
switch (window.deputy.config.cci.signingBehavior.get()) {
case ContributionSurveyRowSigningBehavior.AlwaysTrace:
final = final.map((line) => {
return line.replace(/ ~~~~$/, trace);
});
break;
case ContributionSurveyRowSigningBehavior.AlwaysTraceLastOnly:
final = final.map((line, i) => {
if (i !== lastModifiedRowIndex) {
return line.replace(/ ~~~~$/, trace);
}
else {
return line;
}
});
break;
case ContributionSurveyRowSigningBehavior.LastOnly:
final = final.map((line, i) => {
if (i !== lastModifiedRowIndex) {
return line.replace(/ ~~~~$/, '');
}
else {
return line;
}
});
break;
case ContributionSurveyRowSigningBehavior.Never:
final = final.map((line) => {
return line.replace(/ ~~~~$/, '');
});
break;
}
}
if (this.closed) {
if (!this._section.originallyClosed) {
let closingComments = ((_a = this.comments) !== null && _a !== void 0 ? _a : '').trim();
if (this.closingCommentsSign.isSelected()) {
closingComments += ' ~~~~';
}
final.splice(1, 0, msgEval(window.deputy.wikiConfig.cci.collapseTop.get(), closingComments).plain());
if (final[final.length - 1].trim().length === 0) {
final.pop();
}
final.push(window.deputy.wikiConfig.cci.collapseBottom.get());
}
// If the section was originally closed, don't allow the archiving
// message to be edited.
}
return final.join('\n');
}
/**
* @return The edit summary for this section's changes.
*/
get editSummary() {
var _a;
if (this.modified) {
const modified = this.rows.filter((row) => row.modified);
let worked = 0;
let assessed = 0;
let finished = 0;
let reworked = 0;
for (const row of modified) {
if (!row.wasFinished) {
worked++;
assessed += (_a = row.revisions) === null || _a === void 0 ? void 0 : _a.filter((rev) => rev.completed).length;
if (row.completed) {
finished++;
}
}
else {
reworked++;
}
}
const message = [];
if (assessed > 0) {
message.push(mw.msg('deputy.content.assessed', `${assessed}`, `${worked}`));
}
if (finished > 0) {
message.push(mw.msg('deputy.content.assessed.finished', `${finished}`));
}
if (reworked > 0) {
message.push(mw.msg('deputy.content.assessed.reworked', `${reworked}`));
}
const nowClosed = !this._section.originallyClosed && this.closed;
if (nowClosed) {
message.push(mw.msg('deputy.content.assessed.sectionClosed'));
}
const m = message.join(mw.msg('deputy.content.assessed.comma'));
if (m.length === 0) {
return mw.msg('deputy.content.reformat');
}
const summary = mw.msg(nowClosed ?
'deputy.content.summary.sectionClosed' :
(finished === 0 && assessed > 0 ?
'deputy.content.summary.partial' :
'deputy.content.summary'), this.headingName, finished);
return summary + m[0].toUpperCase() + m.slice(1);
}
else {
return mw.msg('deputy.content.reformat');
}
}
/**
* @return the name of the section heading.
*/
get headingName() {
return this.heading.title;
}
/**
* @return the `n` of the section heading, if applicable.
*/
get headingN() {
return sectionHeadingN(this.heading);
}
/**
* Get the ContributionSurveySection for this section
*
* @param wikitext Internal use only. Used to skip section loading using existing wikitext.
* @return The ContributionSurveySection for this section
*/
getSection(wikitext) {
var _a, _b, _c;
return __awaiter(this, void 0, void 0, function* () {
const collapsible = (_b = (_a = this.sectionNodes.find((v) => v instanceof HTMLElement && v.querySelector('.mw-collapsible'))) === null || _a === void 0 ? void 0 : _a.querySelector('.mw-collapsible')) !== null && _b !== void 0 ? _b : null;
const sectionWikitext = yield this.casePage.wikitext.getSectionWikitext(this.headingName, this.headingN);
return (_c = this._section) !== null && _c !== void 0 ? _c : (this._section = new ContributionSurveySection(this.casePage, this.headingName, collapsible != null, collapsible === null || collapsible === void 0 ? void 0 : collapsible.querySelector('th > div').innerText, wikitext !== null && wikitext !== void 0 ? wikitext : sectionWikitext, wikitext ? wikitext.revid : sectionWikitext.revid));
});
}
/**
* Perform any required pre-render operations.
*
* @return `true` if prepared successfully.
* `false` if not (invalid section, already closed, etc.)
*/
prepare() {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
let targetSectionNodes = this.sectionNodes;
let listElements = this.sectionNodes.filter((el) => el instanceof HTMLElement && el.tagName === 'UL');
if (listElements.length === 0) {
// No list found ! Is this a valid section?
// Check for a collapsible section.
const collapsible = (_b = (_a = this.sectionNodes.find((v) => v instanceof HTMLElement && v.querySelector('.mw-collapsible'))) === null || _a === void 0 ? void 0 : _a.querySelector('.mw-collapsible')) !== null && _b !== void 0 ? _b : null;
if (collapsible) {
// This section has a collapsible. It's possible that it's a closed section.
// From here, use a different `sectionNodes` (specifically targeting all nodes
// inside that collapsible), and then locate all ULs inside that collapsible.
targetSectionNodes = Array.from(collapsible.childNodes);
listElements = Array.from(collapsible.querySelectorAll('ul'));
}
else {
// No collapsible found. Give up.
warn('Could not find valid ULs in CCI section.', targetSectionNodes);
return false;
}
}
const rowElements = {};
for (const listElement of listElements) {
for (let i = 0; i < listElement.children.length; i++) {
const li = listElement.children.item(i);
if (li.tagName !== 'LI') {
// Skip this element.
continue;
}
const anchor = li.querySelector('a:first-of-type');
// Avoid enlisting if the anchor can't be found (invalid row).
if (anchor) {
const anchorLinkTarget = parseDiffUrl(new URL(anchor.getAttribute('href'), window.location.href)).title;
if (!anchorLinkTarget) {
warn('Could not parse target of anchor', anchor);
}
else {
rowElements[new mw.Title(anchorLinkTarget).getPrefixedText()] =
li;
}
}
}
}
const section = yield this.getSection();
const sectionWikitext = section.originalWikitext;
this.revid = section.revid;
const wikitextLines = sectionWikitext.split('\n');
this.rows = [];
this.rowElements = [];
this.wikitextLines = [];
let rowElement;
for (let i = 0; i < wikitextLines.length; i++) {
const line = wikitextLines[i];
try {
const csr = new ContributionSurveyRow(this.casePage, line);
const originalElement = rowElements[csr.title.getPrefixedText()];
if (originalElement) {
rowElement = new DeputyContributionSurveyRow(csr, originalElement, line, this);
}
else {
// Element somehow not in list. Just keep line as-is.
warn(`Could not find row element for "${csr.title.getPrefixedText()}"`);
rowElement = line;
}
}
catch (e) {
// This is not a contribution surveyor row.
if (/^\*[^*:]+/.test(line)) {
// Only trigger on actual bulleted lists.
warn('Could not parse row.', line, e);
// For debugging and tests.
mw.hook('deputy.errors.cciRowParse').fire({
line, error: e.toString()
});
}
if (rowElement instanceof DeputyContributionSurveyRow &&
rowElement.originalElement.nextSibling == null &&
rowElement.originalElement.parentNode.nextSibling != null &&
// Just a blank line. Don't try to do anything else.
line !== '') {
// The previous row element was the last in the list. The
// list probably broke somewhere. (comment with wrong
// bullet?)
// In any case, let's try show it anyway. The user might
// miss some context otherwise.
// We'll only begin reading proper section data once we hit
// another bullet. So let's grab all nodes from the erring
// one until the next bullet list.
const extraneousNodes = [];
let lastNode = rowElement.originalElement.parentElement.nextSibling;
while (
// Another node exists next
lastNode != null &&
// The node is part of this section
targetSectionNodes.includes(lastNode) &&
(
// The node is not an element
!(lastNode instanceof HTMLElement) ||
// The element is not a bullet list
lastNode.tagName !== 'UL')) {
extraneousNodes.push(lastNode);
lastNode = lastNode.nextSibling;
}
rowElement = extraneousNodes;
}
else {
rowElement = line;
}
}
if (rowElement instanceof DeputyContributionSurveyRow) {
this.rows.push(rowElement);
this.rowElements.push(rowElement);
this.wikitextLines.push(rowElement);
}
else if (Array.isArray(rowElement)) {
// Array of Nodes
this.wikitextLines.push(line);
if (rowElement.length !== 0) {
// Only append the row element if it has contents.
// Otherwise, there will be a blank blue box.
this.rowElements.push(DeputyExtraneousElement(rowElement));
}
}
else if (typeof rowElement === 'string') {
this.wikitextLines.push(rowElement);
}
}
// Hide all section elements
this.toggleSectionElements(false);
return true;
});
}
/**
* Toggle section elements. Removes the section elements (but preservers them in
* `this.sectionElements`) if `false`, re-appends them to the DOM if `true`.
* @param toggle
*/
toggleSectionElements(toggle) {
var _a;
const bottom = (_a = this.heading.root.nextSibling) !== null && _a !== void 0 ? _a : null;
for (const sectionElement of this.sectionNodes) {
if (toggle) {
this.heading.root.parentNode.insertBefore(sectionElement, bottom);
}
else {
removeElement(sectionElement);
}
}
}
/**
* Destroys the element from the DOM and re-inserts in its place the original list.
* This *should* return the section back to its original look. This does *NOT*
* remove the section from the session or cache. Use `DeputySession.closeSection`
* instead.
*/
close() {
removeElement(this.container);
this.toggleSectionElements(true);
// Detach listeners to stop listening to events.
this.rows.forEach((row) => {
row.close();
});
}
/**
* Toggles the closing comments input box and signature checkbox.
* This will disable the input box AND hide the element from view.
*
* @param show
*/
toggleClosingElements(show) {
this.closingComments.setDisabled(!show);
this.closingComments.toggle(show);
this.closingCommentsSign.setDisabled(!show);
this.closingCommentsSign.toggle(show);
}
/**
* Sets the disabled state of this section.
*
* @param disabled
*/
setDisabled(disabled) {
var _a, _b, _c, _d, _e, _f, _g;
(_a = this.closeButton) === null || _a === void 0 ? void 0 : _a.setDisabled(disabled);
(_b = this.reviewButton) === null || _b === void 0 ? void 0 : _b.setDisabled(disabled);
(_c = this.saveButton) === null || _c === void 0 ? void 0 : _c.setDisabled(disabled);
(_d = this.closingCheckbox) === null || _d === void 0 ? void 0 : _d.setDisabled(disabled);
(_e = this.closingComments) === null || _e === void 0 ? void 0 : _e.setDisabled(disabled);
(_f = this.closingCommentsSign) === null || _f === void 0 ? void 0 : _f.setDisabled(disabled);
(_g = this.rows) === null || _g === void 0 ? void 0 : _g.forEach((row) => row.setDisabled(disabled));
this.disabled = disabled;
}
/**
* Saves the current section to the case page.
*
* @param sectionId
* @return Save data, or `false` if the save hit an error
*/
save(sectionId) {
return __awaiter(this, void 0, void 0, function* () {
if (sectionId == null) {
throw new Error(mw.msg('deputy.session.section.missingSection'));
}
if (this.closed &&
!this._section.originallyClosed &&
!window.deputy.config.core.dangerMode.get() &&
this.rows.some(r => !r.completed)) {
throw new Error(mw.msg('deputy.session.section.sectionIncomplete'));
}
return MwApi.action.postWithEditToken(Object.assign(Object.assign({}, changeTag(yield window.deputy.getWikiConfig())), { action: 'edit', pageid: this.casePage.pageId, section: sectionId, text: this.wikitext, baserevid: this.revid, summary: decorateEditSummary(this.editSummary, window.deputy.config) })).then(function (data) {
return data;
}, (code, data) => {
if (code === 'editconflict') {
// Wipe cache.
this.casePage.wikitext.resetCachedWikitext();
OO.ui.alert(mw.msg('deputy.session.section.conflict.help'), {
title: mw.msg('deputy.session.section.conflict.title')
}).then(() => {
window.deputy.session.rootSession.restartSession();
});
return false;
}
mw.notify(h_1("span", { dangerouslySetInnerHTML: data.errors[0].html }), {
autoHide: false,
title: mw.msg('deputy.session.section.failed'),
type: 'error'
});
return false;
});
});
}
/**
* Makes all rows of this section being loading data.
*
* @return A Promise that resolves when all rows have finished loading data.
*/
loadData() {
return __awaiter(this, void 0, void 0, function* () {
// For debugging and tests.
// noinspection JSUnresolvedReference
if (window.deputy.NO_ROW_LOADING !== true) {
yield Promise.all(this.rows.map(row => row.loadData()));
}
});
}
/**
* @inheritDoc
*/
render() {
const dangerMode = window.deputy.config.core.dangerMode.get();
this.closingCheckbox = new OO.ui.CheckboxInputWidget({
selected: this._section.originallyClosed,
disabled: this._section.originallyClosed
});
this.closingComments = new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.session.section.closeComments'),
value: this._section.closingComments,
disabled: true
});
this.closingCommentsSign = new OO.ui.CheckboxInputWidget({
selected: window.deputy.config.cci.signSectionArchive.get(),
disabled: true
});
this.closeButton = new OO.ui.ButtonWidget(Object.assign({ label: mw.msg('deputy.session.section.stop'), title: mw.msg('deputy.session.section.stop.title') }, (dangerMode ? { invisibleLabel: true, icon: 'pause' } : {})));
this.reviewButton = new OO.ui.ButtonWidget(Object.assign({ label: mw.msg('deputy.review'), title: mw.msg('deputy.review.title') }, (dangerMode ? { invisibleLabel: true, icon: 'eye' } : {})));
this.saveButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.save'),
flags: ['primary', 'progressive']
});
const saveContainer = h_1("div", { class: "dp-cs-section-progress" }, unwrapWidget(new OO.ui.ProgressBarWidget({
progress: false
})));
this.closeButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
if (this.wikitext !== (yield this.getSection()).originalWikitext) {
dangerModeConfirm(window.deputy.config, mw.msg('deputy.session.section.closeWarn')).done((confirmed) => {
if (confirmed) {
this.close();
window.deputy.session.rootSession.closeSection(this);
}
});
}
else {
this.close();
yield window.deputy.session.rootSession.closeSection(this);
}
}));
this.reviewButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
const reviewDialog = DeputyReviewDialog({
from: (yield this.getSection()).originalWikitext,
to: this.wikitext,
title: this.casePage.title
});
window.deputy.windowManager.addWindows([reviewDialog]);
yield window.deputy.windowManager.openWindow(reviewDialog).opened;
}));
this.saveButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
this.setDisabled(true);
saveContainer.classList.add('active');
const sectionId = yield getSectionId(this.casePage.title, this.headingName, this.headingN);
yield this.save(sectionId).then((result) => __awaiter(this, void 0, void 0, function* () {
var _a;
if (result) {
mw.notify(mw.msg('deputy.session.section.saved'));
// Rebuild the entire section to HTML, and then reopen.
const { element, wikitext, revid } = yield getSectionHTML(this.casePage.title, sectionId);
removeElement(this.container);
// Remove whatever section elements are still there.
// They may have been greatly modified by the save.
const sectionElements = this.casePage.getContributionSurveySection(this.heading.root);
sectionElements.forEach((el) => removeElement(el));
// Clear out section elements and re-append new ones to the DOM.
this.sectionNodes = [];
// Heading is preserved to avoid messing with IDs.
const heading = this.heading.root;
const insertRef = (_a = heading.nextSibling) !== null && _a !== void 0 ? _a : null;
for (const child of Array.from(element.childNodes)) {
if (!this.casePage.isContributionSurveyHeading(child)) {
heading.parentNode.insertBefore(child, insertRef);
this.sectionNodes.push(child);
// noinspection JSUnresolvedReference
$(child).children('.mw-collapsible').makeCollapsible();
}
}
if (!this._section.closed) {
this._section = null;
yield this.getSection(Object.assign(wikitext, { revid }));
yield this.prepare();
heading.insertAdjacentElement('afterend', this.render());
// Run this asynchronously.
setTimeout(this.loadData.bind(this), 0);
}
else {
this.close();
yield window.deputy.session.rootSession.closeSection(this);
}
}
}), (err) => {
OO.ui.alert(err.message, {
title: mw.msg('deputy.session.section.failed')
});
error(err);
saveContainer.classList.remove('active');
this.setDisabled(false);
});
saveContainer.classList.remove('active');
this.setDisabled(false);
}));
// Section closing (archive/ctop) elements
const closingWarning = DeputyMessageWidget({
classes: ['dp-cs-section-unfinishedWarning'],
type: 'error',
label: mw.msg('deputy.session.section.closeError')
});
closingWarning.toggle(false);
const updateClosingWarning = (() => {
const incomplete = this.rows.some((row) => !row.completed);
if (window.deputy.config.core.dangerMode.get()) {
this.saveButton.setDisabled(false);
closingWarning.setLabel(mw.msg('deputy.session.section.closeError.danger'));
}
else {
closingWarning.setLabel(mw.msg('deputy.session.section.closeError'));
this.saveButton.setDisabled(incomplete);
}
closingWarning.toggle(incomplete);
});
const closingCommentsField = new OO.ui.FieldLayout(this.closingComments, {
align: 'top',
label: mw.msg('deputy.session.section.closeComments'),
invisibleLabel: true,
helpInline: true,
classes: ['dp-cs-section-closingCommentsField']
});
const closingCommentsSignField = new OO.ui.FieldLayout(this.closingCommentsSign, {
align: 'inline',
label: mw.msg('deputy.session.section.closeCommentsSign')
});
const closingFields = h_1("div", { class: "dp-cs-section-closing", style: { display: 'none' } },
unwrapWidget(closingWarning),
unwrapWidget(closingCommentsField),
unwrapWidget(closingCommentsSignField));
const updateClosingFields = (v) => {
this.closed = v;
if (this._section.originallyClosed) {
// This section was originally closed. Hide everything.
v = false;
}
closingFields.style.display = v ? '' : 'none';
this.toggleClosingElements(v);
if (v) {
updateClosingWarning();
this.rows.forEach((row) => {
row.addEventListener('update', updateClosingWarning);
});
}
else {
closingWarning.toggle(false);
this.saveButton.setDisabled(false);
this.rows.forEach((row) => {
row.removeEventListener('update', updateClosingWarning);
});
}
};
this.closingCheckbox.on('change', updateClosingFields);
updateClosingFields(this.closed);
this.closingComments.on('change', (v) => {
this.comments = v;
});
// Danger mode buttons
const dangerModeElements = [];
if (dangerMode) {
const markAllFinishedButton = new OO.ui.ButtonWidget({
flags: ['destructive'],
icon: 'checkAll',
label: mw.msg('deputy.session.section.markAllFinished'),
title: mw.msg('deputy.session.section.markAllFinished'),
invisibleLabel: true
});
markAllFinishedButton.on('click', () => {
this.rows.forEach(v => v.markAllAsFinished());
});
const instantArchiveButton = new OO.ui.ButtonWidget({
flags: ['destructive', 'primary'],
label: mw.msg('deputy.session.section.instantArchive'),
title: mw.msg('deputy.session.section.instantArchive.title')
});
instantArchiveButton.on('click', () => {
this.closingCheckbox.setSelected(true);
this.saveButton.emit('click');
});
const dangerModeButtons = [
unwrapWidget(markAllFinishedButton),
unwrapWidget(instantArchiveButton)
];
dangerModeElements.push(h_1("div", { class: "dp-cs-section-danger--separator" }, mw.msg('deputy.session.section.danger')), dangerModeButtons);
// Remove spacing from save button
unwrapWidget(this.saveButton).style.marginRight = '0';
}
// Actual element
return this.container = h_1("div", { class: classMix('deputy', 'dp-cs-section', this._section.originallyClosed && 'dp-cs-section-archived') },
this._section.originallyClosed && h_1("div", { class: "dp-cs-section-archived-warn" }, unwrapWidget(new OO.ui.MessageWidget({
type: 'warning',
label: mw.msg('deputy.session.section.closed')
}))),
h_1("div", null, this.rowElements.map((row) => row instanceof HTMLElement ? row : row.render())),
h_1("div", { class: "dp-cs-section-footer" },
h_1("div", { style: { display: 'flex' } },
h_1("div", { style: {
flex: '1 1 100%',
display: 'flex',
flexDirection: 'column'
} },
unwrapWidget(new OO.ui.FieldLayout(this.closingCheckbox, {
align: 'inline',
label: mw.msg('deputy.session.section.close')
})),
closingFields),
h_1("div", { style: {
display: 'flex',
alignContent: 'end',
justifyContent: 'end',
flexWrap: dangerMode ? 'wrap' : 'nowrap',
maxWidth: '320px'
} },
unwrapWidget(this.closeButton),
unwrapWidget(this.reviewButton),
unwrapWidget(this.saveButton),
dangerModeElements)),
saveContainer));
}
}
/**
*
* @param element
*/
function findNextSiblingElement(element) {
if (element == null) {
return null;
}
let anchor = element.nextSibling;
while (anchor && !(anchor instanceof Element)) {
anchor = anchor.nextSibling;
}
return anchor;
}
/**
* The DeputyRootSession. Instantiated only when:
* (a) the page is a CCI case page, and
* (b) a session is currently active
*/
class DeputyRootSession {
/*
* =========================================================================
* INSTANCE AND ACTIVE SESSION FUNCTIONS
* =========================================================================
*/
/**
* @param session
* @param casePage
*/
constructor(session, casePage) {
/**
* Responder for session requests.
*/
this.sessionRequestResponder = this.sendSessionResponse.bind(this);
this.sessionStopResponder = this.handleStopRequest.bind(this);
this.session = session;
this.casePage = casePage;
}
/*
* =========================================================================
* STATIC AND SESSION-LESS FUNCTIONS
* =========================================================================
*/
/**
* Initialize interface components for *starting* a session. This includes
* the `[start CCI session]` notice at the top of each CCI page section heading.
*
* @param _casePage The current case page
*/
static initEntryInterface(_casePage) {
return __awaiter(this, void 0, void 0, function* () {
const continuing = _casePage != null;
const casePage = continuing ? _casePage : yield DeputyCasePage.build();
const startLink = [];
casePage.findContributionSurveyHeadings()
.forEach((heading) => {
const normalizedHeading = normalizeWikiHeading(heading);
const link = DeputyCCISessionStartLink(normalizedHeading, casePage);
startLink.push(link);
normalizedHeading.root.appendChild(link);
});
window.deputy.comms.addEventListener('sessionStarted', () => {
// Re-build interface.
startLink.forEach((link) => {
removeElement(link);
});
window.deputy.session.init();
}, { once: true });
});
}
/**
* Shows the interface for overwriting an existing session. The provided
* action button will close the other section. This does not start a new
* session; the user must start the session on this page on their own.
*
* @param casePage The case page to continue with
*/
static initOverwriteMessage(casePage) {
return __awaiter(this, void 0, void 0, function* () {
yield mw.loader.using(['oojs-ui-core', 'oojs-ui.styles.icons-content'], () => {
const firstHeading = casePage.findFirstContributionSurveyHeadingElement();
if (firstHeading) {
const stopButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.session.otherActive.button'),
flags: ['primary', 'destructive']
});
const messageBox = DeputyMessageWidget({
classes: [
'deputy', 'dp-cs-session-notice', 'dp-cs-session-otherActive'
],
type: 'notice',
icon: 'alert',
title: mw.msg('deputy.session.otherActive.head'),
message: mw.msg('deputy.session.otherActive.help'),
actions: [stopButton],
closable: true
});
stopButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
const session = yield window.deputy.comms.sendAndWait({
type: 'sessionStop'
});
if (session === null) {
// Session did not close cleanly. Tab must be closed. Force-stop
// the session.
yield window.deputy.session.clearSession();
removeElement(unwrapWidget(messageBox));
yield window.deputy.session.init();
}
}));
window.deputy.comms.addEventListener('sessionClosed', () => {
// Closed externally. Re-build interface.
removeElement(unwrapWidget(messageBox));
window.deputy.session.init();
});
normalizeWikiHeading(firstHeading).root.insertAdjacentElement('beforebegin', unwrapWidget(messageBox));
}
});
});
}
/**
* Shows the interface for continuing a previous session. This includes
* the `[continue CCI session]` notice at the top of each CCI page section heading
* and a single message box showing when the page was last worked on top of the
* first CCI heading found.
*
* @param casePage The case page to continue with
*/
static initContinueInterface(casePage) {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all([
DeputyRootSession.initEntryInterface(),
mw.loader.using(['oojs-ui-core', 'oojs-ui.styles.icons-content'], () => {
const lastActiveSection = DeputyRootSession.findFirstLastActiveSection(casePage);
const firstSection = normalizeWikiHeading(casePage.findFirstContributionSurveyHeadingElement());
// Insert element directly into widget (not as text, or else event
// handlers will be destroyed).
const continueButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.session.continue.button'),
flags: ['primary', 'progressive']
});
const messageBox = DeputyMessageWidget({
classes: [
'deputy', 'dp-cs-session-notice', 'dp-cs-session-lastActive'
],
type: 'notice',
icon: 'history',
title: mw.msg('deputy.session.continue.head', new Date().toLocaleString(mw.config.get('wgUserLanguage'), { dateStyle: 'long', timeStyle: 'medium' })),
message: mw.msg(lastActiveSection ?
'deputy.session.continue.help' :
'deputy.session.continue.help.fromStart', lastActiveSection ?
normalizeWikiHeading(lastActiveSection).title :
casePage.lastActiveSections[0]
.replace(/_/g, ' '), firstSection.title),
actions: [continueButton],
closable: true
});
const sessionStartListener = () => __awaiter(this, void 0, void 0, function* () {
removeElement(unwrapWidget(messageBox));
yield this.initTabActiveInterface();
});
continueButton.on('click', () => {
removeElement(unwrapWidget(messageBox));
if (lastActiveSection) {
DeputyRootSession.continueSession(casePage);
}
else {
DeputyRootSession.continueSession(casePage, [
firstSection.id
]);
}
window.deputy.comms.removeEventListener('sessionStarted', sessionStartListener);
});
firstSection.root.insertAdjacentElement('beforebegin', unwrapWidget(messageBox));
window.deputy.comms.addEventListener('sessionStarted', sessionStartListener, { once: true });
})
]);
});
}
/**
* Shows the interface for an attempted Deputy execution on a different tab than
* expected. This prevents Deputy from running entirely to avoid loss of progress
* and desynchronization.
*
* @param _casePage The current case page (not the active one)
*/
static initTabActiveInterface(_casePage) {
return __awaiter(this, void 0, void 0, function* () {
const casePage = _casePage !== null && _casePage !== void 0 ? _casePage : yield DeputyCasePage.build();
return mw.loader.using(['oojs-ui-core', 'oojs-ui.styles.icons-content'], () => {
const firstHeading = casePage.findFirstContributionSurveyHeadingElement();
if (firstHeading) {
const messageBox = DeputyMessageWidget({
classes: [
'deputy', 'dp-cs-session-notice', 'dp-cs-session-tabActive'
],
type: 'notice',
title: mw.msg('deputy.session.tabActive.head'),
message: mw.msg('deputy.session.tabActive.help'),
closable: true
});
normalizeWikiHeading(firstHeading).root.insertAdjacentElement('beforebegin', unwrapWidget(messageBox));
window.deputy.comms.addEventListener('sessionClosed', () => __awaiter(this, void 0, void 0, function* () {
removeElement(unwrapWidget(messageBox));
yield window.deputy.session.init();
}), { once: true });
}
});
});
}
/**
* Finds the first last active section that exists on the page.
* If a last active section that still exists on the page could not be found,
* `null` is returned.
*
* @param casePage The case page to use
* @return The last active session's heading element.
*/
static findFirstLastActiveSection(casePage) {
const csHeadings = casePage.findContributionSurveyHeadings();
for (const lastActiveSection of casePage.lastActiveSections) {
for (const heading of csHeadings) {
if (normalizeWikiHeading(heading).id === lastActiveSection) {
return heading;
}
}
}
return null;
}
/**
* Starts a Deputy session.
*
* @param section
* @param _casePage
*/
static startSession(section, _casePage) {
return __awaiter(this, void 0, void 0, function* () {
const sectionIds = (Array.isArray(section) ? section : [section]).map((_section) => normalizeWikiHeading(_section).id);
// Save session to storage
const casePage = _casePage !== null && _casePage !== void 0 ? _casePage : yield DeputyCasePage.build();
const session = yield this.setSession({
casePageId: casePage.pageId,
caseSections: sectionIds
});
const rootSession = window.deputy.session.rootSession =
new DeputyRootSession(session, casePage);
yield casePage.bumpActive();
yield rootSession.initSessionInterface();
});
}
/**
* Continue a session from a DeputyCasePage.
*
* @param casePage The case page to continue with
* @param sectionIds The section IDs to load on startup. If not provided, this will be
* taken from the cache. If provided, this overrides the cache, discarding any
* sections cached previously.
*/
static continueSession(casePage, sectionIds) {
return __awaiter(this, void 0, void 0, function* () {
// Save session to storage
if (sectionIds) {
casePage.lastActiveSections = sectionIds;
}
const session = yield this.setSession({
casePageId: casePage.pageId,
// Shallow array copy
caseSections: [...casePage.lastActiveSections]
});
const rootSession = window.deputy.session.rootSession =
new DeputyRootSession(session, casePage);
yield casePage.bumpActive();
yield rootSession.initSessionInterface();
});
}
/**
* Sets the current active session information.
*
* @param session The session to save.
* @return SessionInformation object if successful, `null` if not.
*/
static setSession(session) {
return __awaiter(this, void 0, void 0, function* () {
return (yield window.deputy.storage.setKV('session', session)) ? session : null;
});
}
/**
* Initialize interface components for an active session. This will always run in the
* context of a CCI case page.
*/
initSessionInterface() {
return __awaiter(this, void 0, void 0, function* () {
if (window.location.search.indexOf('action=edit') !== -1) {
// User is editing, don't load interface.
return;
}
if (yield window.deputy.session.checkForActiveSessionTabs()) {
// User is on another tab, don't load interface.
mw.loader.using(['oojs-ui-core', 'oojs-ui-windows'], () => {
OO.ui.alert(mw.msg('deputy.session.tabActive.help'), { title: mw.msg('deputy.session.tabActive.head') });
});
return;
}
removeElement(this.casePage.document.querySelector('.dp-cs-session-lastActive'));
this.casePage.document.querySelectorAll('.dp-sessionStarter')
.forEach((el) => {
removeElement(el);
});
window.deputy.comms.addEventListener('sessionRequest', this.sessionRequestResponder);
window.deputy.comms.addEventListener('sessionStop', this.sessionStopResponder);
window.deputy.comms.send({ type: 'sessionStarted', caseId: this.session.casePageId });
yield new Promise((res) => {
mw.loader.using([
'mediawiki.special.changeslist',
'mediawiki.interface.helpers.styles',
'mediawiki.pager.styles',
'oojs-ui-core',
'oojs-ui-windows',
'oojs-ui-widgets',
'oojs-ui.styles.icons-alerts',
'oojs-ui.styles.icons-content',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-interactions',
'oojs-ui.styles.icons-media',
'oojs-ui.styles.icons-movement',
'ext.discussionTools.init',
'jquery.makeCollapsible'
], (require) => __awaiter(this, void 0, void 0, function* () {
// Instantiate the parser
const dt = require('ext.discussionTools.init');
this.parser = new dt.Parser(dt.parserData);
document.getElementsByTagName('body')[0]
.appendChild(window.deputy.windowManager.$element[0]);
// TODO: Do interface functions
this.sections = [];
const activeSectionPromises = [];
for (const heading of this.casePage.findContributionSurveyHeadings()) {
const headingId = normalizeWikiHeading(heading).id;
if (this.session.caseSections.indexOf(headingId) !== -1) {
activeSectionPromises.push(this.activateSection(this.casePage, heading)
.then(v => v ? headingId : null));
}
else {
this.addSectionOverlay(this.casePage, heading);
}
}
// Strip missing sections from caseSections.
this.session.caseSections = (yield Promise.all(activeSectionPromises))
.filter(v => !!v);
yield DeputyRootSession.setSession(this.session);
if (this.session.caseSections.length === 0) {
// No sections re-opened. All of them might have been removed or closed already.
// Close this entire session.
yield this.closeSession();
}
mw.hook('deputy.load.cci.root').fire();
res();
}));
});
});
}
/**
* Responds to session requests through the Deputy communicator. This prevents two
* tabs from having the same session opened.
*
* @param event
*/
sendSessionResponse(event) {
window.deputy.comms.reply(event.data, {
type: 'sessionResponse',
caseId: this.session.casePageId,
sections: this.session.caseSections
});
}
/**
* Handles a session stop request.
*
* @param event
*/
handleStopRequest(event) {
return __awaiter(this, void 0, void 0, function* () {
yield this.closeSession();
window.deputy.comms.reply(event.data, {
type: 'acknowledge'
});
});
}
/**
* Adds the "start working on this section" or "reload page" overlay and button
* to a given section.
*
* @param casePage
* @param heading
*/
addSectionOverlay(casePage, heading) {
var _a, _b, _c;
const normalizedHeading = normalizeWikiHeading(heading).root;
const section = casePage.getContributionSurveySection(normalizedHeading);
const list = section.find((v) => v instanceof HTMLElement && v.tagName === 'UL');
const headingTop = window.scrollY +
normalizedHeading.getBoundingClientRect().bottom;
const sectionBottom = window.scrollY + ((_c = (_b = (_a = findNextSiblingElement(last(section))) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect()) === null || _b === void 0 ? void 0 : _b.top) !== null && _c !== void 0 ? _c : normalizedHeading.parentElement.getBoundingClientRect().bottom);
const overlayHeight = sectionBottom - headingTop;
if (list != null) {
list.style.position = 'relative';
list.appendChild(DeputyCCISessionAddSection({
casePage, heading,
height: overlayHeight
}));
}
}
/**
* Closes all active session-related UI components. Done prior to closing
* a section or reloading the interface.
*/
closeSessionUI() {
if (this.sections) {
for (const section of this.sections) {
section.close();
}
}
this.casePage.document.querySelectorAll('.dp-cs-section-add')
.forEach((el) => removeElement(el));
}
/**
* Closes the current session.
*/
closeSession() {
return __awaiter(this, void 0, void 0, function* () {
this.closeSessionUI();
yield this.casePage.saveToCache();
const oldSessionId = this.session.casePageId;
window.deputy.comms.removeEventListener('sessionRequest', this.sessionRequestResponder);
yield window.deputy.session.clearSession();
window.deputy.comms.send({ type: 'sessionClosed', caseId: oldSessionId });
// Re-initialize session interface objects.
yield window.deputy.session.init();
});
}
/**
* Activates a section. This appends the section UI, adds the section to the
* cache (if not already added), and internally stores the section for a
* graceful exit.
*
* @param casePage
* @param heading
* @return `true` if the section was activated successfully
*/
activateSection(casePage, heading) {
return __awaiter(this, void 0, void 0, function* () {
const el = new DeputyContributionSurveySection(casePage, heading);
if (!(yield el.prepare())) {
return false;
}
const sectionId = normalizeWikiHeading(heading).id;
this.sections.push(el);
const lastActiveSession = this.session.caseSections.indexOf(sectionId);
if (lastActiveSession === -1) {
this.session.caseSections.push(sectionId);
yield DeputyRootSession.setSession(this.session);
}
yield casePage.addActiveSection(sectionId);
normalizeWikiHeading(heading).root.insertAdjacentElement('afterend', el.render());
yield el.loadData();
mw.hook('deputy.load.cci.session').fire();
return true;
});
}
/**
* Closes a section. This removes the section from both the session data and from
* the case page cache.
*
* @param e0
* @param e1
*/
closeSection(e0, e1) {
return __awaiter(this, void 0, void 0, function* () {
const el = e0 instanceof DeputyContributionSurveySection ?
e0 : null;
const casePage = e0 instanceof DeputyContributionSurveySection ?
e0.casePage : e0;
const heading = e0 instanceof DeputyContributionSurveySection ?
e0.heading : normalizeWikiHeading(e1);
const sectionId = heading.id;
const sectionListIndex = this.sections.indexOf(el);
if (el != null && sectionListIndex !== -1) {
this.sections.splice(sectionListIndex, 1);
}
const lastActiveSection = this.session.caseSections.indexOf(sectionId);
if (lastActiveSection !== -1) {
this.session.caseSections.splice(lastActiveSection, 1);
// If no sections remain, clear the session.
if (this.session.caseSections.length === 0) {
yield this.closeSession();
// Don't remove from casePage if there are no sections left, or
// else "continue where you left off" won't work.
}
else {
yield DeputyRootSession.setSession(this.session);
yield casePage.removeActiveSection(sectionId);
this.addSectionOverlay(casePage, heading.h);
}
}
});
}
/**
* Restarts the section. This rebuilds *everything* from the ground up, which may
* be required when there's an edit conflict.
*/
restartSession() {
return __awaiter(this, void 0, void 0, function* () {
const casePage = this.casePage;
yield this.closeSession();
yield window.deputy.session.DeputyRootSession.continueSession(casePage);
});
}
}
/**
* Fake document. Used to load in entire HTML pages without having to append them to the
* actual DOM or use JQuery.
*/
class FakeDocument {
/**
* @param data Data to include in the iframe
*/
constructor(data) {
this.ready = false;
this.iframe = document.createElement('iframe');
this.iframe.style.display = 'none';
this.iframe.addEventListener('load', () => {
this.ready = true;
});
this.iframe.src = URL.createObjectURL(data instanceof Blob ? data : new Blob(data));
// Disables JavaScript, modals, popups, etc., but allows same-origin access.
this.iframe.setAttribute('sandbox', 'allow-same-origin');
document.getElementsByTagName('body')[0].appendChild(this.iframe);
}
/**
* Creates a fake document and waits for the `document` to be ready.
*
* @param data Data to include in the iframe
*/
static build(data) {
return __awaiter(this, void 0, void 0, function* () {
const fakeDoc = new FakeDocument(data);
yield fakeDoc.waitForDocument();
return fakeDoc;
});
}
/**
* @return The document of the iframe
*/
get document() {
return this.iframe.contentDocument;
}
/**
* Returns the Document of the iframe when ready.
*/
waitForDocument() {
return __awaiter(this, void 0, void 0, function* () {
while (!this.ready ||
this.document == null ||
!this.document.getElementsByTagName('body')[0]
.classList.contains('mediawiki')) {
yield new Promise((res) => {
setTimeout(res, 10);
});
}
return this.document;
});
}
/**
* Performs cleanup
*/
close() {
removeElement(this.iframe);
}
}
/**
*
*/
class DiffPage {
/**
* Reloads the current diff page. Takes inspiration from Extension:RevisionSlider.
*
* @param diff
* @param options
* @see https://w.wiki/5Roy
*/
static loadNewDiff(diff, options = {}) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const diffUrl = getRevisionDiffURL(diff, (_a = options.oldid) !== null && _a !== void 0 ? _a : null, true);
const contentText = document.querySelector('#mw-content-text');
contentText.classList.add('dp-reloading');
const diffDoc = yield fetch(diffUrl)
.then((r) => r.blob(), () => {
mw.loader.using([
'oojs-ui-core', 'oojs-ui-windows'
], () => {
OO.ui.alert(mw.msg('deputy.session.page.diff.loadFail'));
});
return null;
})
.then((b) => b == null ? null : FakeDocument.build(b))
.then((d) => d);
if (diffDoc == null) {
return;
}
const newContentText = diffDoc.document.querySelector('#mw-content-text');
swapElements(contentText, newContentText);
document.querySelectorAll('#ca-edit a, #ca-ve-edit a').forEach((e) => {
const newEditUrl = new URL(e.getAttribute('href'), window.location.href);
newEditUrl.searchParams.set('oldid', `${diff}`);
e.setAttribute('href', newEditUrl.href);
});
// Extract wgDiffOldId from HTML (because JavaScript remains unparsed and oldid
// (from parameters) might be a null value.
const oldid = (_b = /"wgDiffOldId":\s*(\d+)/g.exec(diffDoc.document.head.outerHTML)) === null || _b === void 0 ? void 0 : _b[1];
// T161257
mw.config.set({
wgRevisionId: diff,
wgDiffOldId: +oldid,
wgDiffNewId: diff
});
// Forgetting JQuery ban for now. Backwards-compat reasons.
mw.hook('wikipage.content').fire($(newContentText));
mw.hook('wikipage.diff').fire($(document.querySelector('body > table.diff')));
history.pushState({}, null, diffUrl);
});
}
}
/**
* Utility class for generating URLs to Earwig's Copyvio Detector.
*/
class EarwigCopyvioDetector {
/**
* Guesses the current project and language.
*
* @param project
* @param language
* @return The (guessed) project and language
*/
static guessProject(project, language) {
// Attempt to guess the language and project.
const splitHost = window.location.host.split('.');
if (!project && splitHost[splitHost.length - 2]) {
// Project (e.g. wikipedia)
project = splitHost[splitHost.length - 2];
}
if (!language && splitHost[splitHost.length - 3]) {
// Language (e.g. en)
language = splitHost[splitHost.length - 3];
}
return { project, language };
}
/**
* Get Earwig's Copyvio Detector's supported languages and projects.
*/
static getSupported() {
return __awaiter(this, void 0, void 0, function* () {
if (!!this.supportedLanguages && !!this.supportedProjects) {
// Already loaded.
return;
}
const cachedSupportedRaw = window.sessionStorage.getItem('dp-earwig-supported');
if (cachedSupportedRaw) {
const cachedSupported = JSON.parse(cachedSupportedRaw);
this.supportedLanguages = cachedSupported.languages;
this.supportedProjects = cachedSupported.projects;
}
const sites = yield fetch(`${(yield window.deputy.getWikiConfig()).cci.earwigRoot.get()}/api.json?action=sites&version=1`)
.then((r) => r.json());
this.supportedLanguages = [];
for (const lang of sites.langs) {
this.supportedLanguages.push(lang[0]);
}
this.supportedProjects = [];
for (const project of sites.projects) {
this.supportedProjects.push(project[0]);
}
window.sessionStorage.setItem('dp-earwig-supported', JSON.stringify({
languages: this.supportedLanguages,
projects: this.supportedProjects
}));
});
}
/**
* Checks if this wiki is supported by Earwig's Copyvio Detector.
*
* @param _project The project to check for
* @param _language The language to check for
*/
static supports(_project, _language) {
return __awaiter(this, void 0, void 0, function* () {
yield this.getSupported();
const { project, language } = this.guessProject(_project, _language);
return EarwigCopyvioDetector.supportedProjects.indexOf(project) !== -1 &&
EarwigCopyvioDetector.supportedLanguages.indexOf(language) !== -1;
});
}
/**
* Generates a URL for Earwig's Copyvio Detector.
*
* @param target
* @param options
* @return An Earwig Copyvio Detector execution URL. `null` if wiki is not supported.
*/
static getUrl(target, options = {}) {
var _a, _b, _c;
return __awaiter(this, void 0, void 0, function* () {
if (!(yield this.supports())) {
return null;
}
const { project, language } = this.guessProject(options.project, options.language);
return `${(yield window.deputy.getWikiConfig()).cci.earwigRoot.get()}?action=search&lang=${language}&project=${project}&${typeof target === 'number' ?
'oldid=' + target :
'title=' + target.getPrefixedText()}&use_engine=${((_a = options.useEngine) !== null && _a !== void 0 ? _a : true) ? 1 : 0}&use_links=${((_b = options.useLinks) !== null && _b !== void 0 ? _b : true) ? 1 : 0}&turnitin=${((_c = options.turnItIn) !== null && _c !== void 0 ? _c : false) ? 1 : 0}`;
});
}
}
/**
* Renders a MenuLayout responsible for displaying analysis options or tools.
*/
class DeputyPageMenu {
/**
* @param options
* @param toolbar
* @param baseWidget
*/
constructor(options, toolbar, baseWidget) {
this.options = options;
this.toolbar = toolbar;
this.baseWidget = baseWidget;
}
/**
* @inheritDoc
*/
render() {
const menuItems = new Map();
const menuSelectWidget = new OO.ui.MenuSelectWidget({
autoHide: false,
hideWhenOutOfView: false,
verticalPosition: 'above',
horizontalPosition: 'start',
widget: this.baseWidget,
$floatableContainer: this.baseWidget.$element,
items: this.options.map((option, i) => {
const item = new OO.ui.MenuOptionWidget({
data: i,
disabled: option.condition ? !(option.condition(this.toolbar)) : false,
icon: option.icon,
label: option.label
});
menuItems.set(item, option);
return item;
})
});
menuSelectWidget.on('select', () => {
// Not a multiselect MenuSelectWidget
const selected = menuSelectWidget.findSelectedItem();
if (selected) {
this.options[selected.getData()].action(this.toolbar);
// Clear selections.
menuSelectWidget.selectItem();
this.baseWidget.setValue(false);
}
});
// Disables clipping (allows the menu to be wider than the button)
menuSelectWidget.toggleClipping(false);
this.baseWidget.on('change', (toggled) => {
// Recalculate disabled condition
menuItems.forEach((option, item) => {
item.setDisabled(option.condition ? !(option.condition(this.toolbar)) : false);
});
menuSelectWidget.toggle(toggled);
});
return unwrapWidget(menuSelectWidget);
}
}
var deputyPageAnalysisOptions = () => [
{
icon: 'eye',
label: mw.msg('deputy.session.page.earwigLatest'),
action: (toolbar) => __awaiter(void 0, void 0, void 0, function* () {
const url = yield EarwigCopyvioDetector.getUrl(toolbar.row.title);
window.open(url, '_blank', 'noopener');
if (url == null) {
mw.notify(mw.msg('deputy.session.page.earwigUnsupported'), {
type: 'error'
});
}
else {
window.open(url, '_blank', 'noopener');
}
})
},
{
icon: 'eye',
label: mw.msg('deputy.session.page.earwigRevision'),
condition: (toolbar) => toolbar.revision != null,
action: (toolbar) => __awaiter(void 0, void 0, void 0, function* () {
const url = yield EarwigCopyvioDetector.getUrl(toolbar.revision);
if (url == null) {
mw.notify(mw.msg('deputy.session.page.earwigUnsupported'), {
type: 'error'
});
}
else {
window.open(url, '_blank', 'noopener');
}
})
},
{
icon: 'references',
label: mw.msg('deputy.session.page.iabot'),
action: (toolbar) => __awaiter(void 0, void 0, void 0, function* () {
const url = new URL('https://iabot.toolforge.org/index.php');
url.searchParams.set('page', 'runbotsingle');
url.searchParams.set('pagesearch', toolbar.row.title.getPrefixedText());
url.searchParams.set('archiveall', 'on');
url.searchParams.set('wiki', 'enwiki');
url.searchParams.set('reason', mw.msg('deputy.session.page.iabot.reason'));
window.open(url, '_blank', 'noopener');
})
}
];
var deputyPageTools = () => [
{
icon: 'copy',
label: mw.msg('deputy.ante'),
condition: () => window.deputy.ante.startState,
action: () => __awaiter(void 0, void 0, void 0, function* () {
window.deputy.ante.openEditDialog();
})
},
{
icon: 'flag',
label: mw.msg('deputy.ia'),
condition: () => true,
action: () => __awaiter(void 0, void 0, void 0, function* () {
yield window.deputy.ia.openWorkflowDialog();
})
}
];
/**
* The DeputyPageToolbar is appended to all pages (outside the mw-parser-output block)
* that are part of the currently-active case page. It includes the status dropdown,
* page name, basic case info, and analysis tools.
*
* The toolbar automatically connects with an existing session through the use of
* inter-tab communication (facilitated by DeputyCommunications).
*/
class DeputyPageToolbar {
/**
* @param options The data received from a page status request.
* Used to initialize some values.
*/
constructor(options) {
var _a;
this.instanceId = generateId();
this.revisionStatusUpdateListener = this.onRevisionStatusUpdate.bind(this);
this.options = options;
if (options.revisionStatus != null) {
this.revision = (_a = options.revision) !== null && _a !== void 0 ? _a : mw.config.get('wgRevisionId');
}
this.runAsyncJobs();
}
/**
* Runs asynchronous preparation jobs. Makes loading more seamless later in execution,
* as this will run functions that cache data in the background.
*/
runAsyncJobs() {
return __awaiter(this, void 0, void 0, function* () {
yield EarwigCopyvioDetector.getSupported();
});
}
/**
* @inheritDoc
*/
prepare() {
return __awaiter(this, void 0, void 0, function* () {
this.row = {
casePage: yield DeputyCase.build(this.options.caseId, normalizeTitle(this.options.caseTitle)),
title: normalizeTitle(this.options.title),
type: this.options.rowType
};
});
}
/**
* Instantiates a DeputyCCIStatusDropdown and returns the HTML element for it.
*
* @return The OOUI dropdown's HTMLElement
*/
renderStatusDropdown() {
this.statusDropdown = new DeputyCCIStatusDropdown(this.row, {
status: this.options.status,
enabled: this.options.enabledStatuses
});
this.statusDropdown.addEventListener('updateFail', () => {
OO.ui.alert(mw.msg('deputy.session.page.incommunicable'));
});
return unwrapWidget(this.statusDropdown.dropdown);
}
/**
* Renders the "current case" section on the toolbar.
*
* @return The "current case" section
*/
renderCaseInfo() {
return h_1("div", { class: "dp-pt-section" },
h_1("div", { class: "dp-pt-section-label" }, mw.msg('deputy.session.page.caseInfo.label')),
h_1("a", { class: "dp-pt-section-content dp-pt-caseInfo" }, this.row.casePage.getCaseName()));
}
/**
* Renders the "revision" section on the toolbar.
*
* @return The "Revision #XXXXXXXXXX" section
*/
renderRevisionInfo() {
var _a;
if (this.revision == null) {
if (
// Show if forced, or if we're not looking at the latest revision.
mw.config.get('wgRevisionId') !== mw.config.get('wgCurRevisionId') ||
((_a = this.options.forceRevision) !== null && _a !== void 0 ? _a : true)) {
return this.renderMissingRevisionInfo();
}
else {
return null;
}
}
this.revisionCheckbox = new OO.ui.CheckboxInputWidget({
title: mw.msg('deputy.session.revision.assessed'),
selected: this.options.revisionStatus
});
let lastStatus = this.revisionCheckbox.isSelected();
// State variables
let processing = false;
let incommunicable = false;
this.revisionCheckbox.on('change', (selected) => __awaiter(this, void 0, void 0, function* () {
if (incommunicable) {
incommunicable = false;
return;
}
else if (processing) {
return;
}
processing = true;
const response = yield window.deputy.comms.sendAndWait({
type: 'revisionStatusUpdate',
caseId: this.row.casePage.pageId,
page: this.row.title.getPrefixedText(),
revision: this.revision,
status: selected,
nextRevision: null
});
if (response == null) {
OO.ui.alert(mw.msg('deputy.session.page.incommunicable'));
// Sets flag to avoid running this listener twice.
incommunicable = true;
this.revisionCheckbox.setSelected(lastStatus);
}
else {
// Replace the last status for "undo".
lastStatus = this.revisionCheckbox.isSelected();
}
processing = false;
}));
window.deputy.comms.addEventListener('revisionStatusUpdate', this.revisionStatusUpdateListener);
return h_1("div", { class: "dp-pt-section" },
h_1("div", { class: "dp-pt-section-label" }, mw.message('deputy.session.page.caseInfo.revision', `${this.revision}`).text()),
h_1("div", { class: "dp-pt-section-content" }, unwrapWidget(new OO.ui.FieldLayout(this.revisionCheckbox, {
align: 'inline',
label: mw.msg('deputy.session.page.caseInfo.assessed')
}))));
}
/**
* Replaces `renderRevisionInfo` if a revision does not exist. Placeholder to
* allow tools to be used anyway, even without having an active revision associated.
*
* @return The "Revision out of scope" section
*/
renderMissingRevisionInfo() {
const helpPopup = new OO.ui.PopupButtonWidget({
icon: 'info',
framed: false,
label: mw.msg('deputy.moreInfo'),
invisibleLabel: true,
popup: {
head: true,
padded: true,
label: mw.msg('deputy.moreInfo'),
align: 'forwards'
}
});
unwrapWidget(helpPopup).querySelector('.oo-ui-popupWidget-body')
.appendChild(h_1("p", null, mw.msg('deputy.session.page.caseInfo.revision.help')));
return h_1("div", { class: "dp-pt-section" },
h_1("div", { class: "dp-pt-section-label" }, mw.msg('deputy.session.page.caseInfo.revision.none')),
h_1("div", { class: "dp-pt-section-content dp-pt-missingRevision" }, unwrapWidget(helpPopup)));
}
/**
* Renders the next revision button. Used to navigate to the next unassessed revision
* for a row.
*
* @return The OOUI ButtonWidget element.
*/
renderRevisionNavigationButtons() {
if (this.row.type === 'pageonly') {
return h_1("div", { class: "dp-pt-section" }, unwrapWidget(new OO.ui.PopupButtonWidget({
icon: 'info',
framed: false,
label: mw.msg('deputy.session.page.pageonly.title'),
popup: {
head: true,
icon: 'infoFilled',
label: mw.msg('deputy.session.page.pageonly.title'),
$content: $(h_1("p", null, mw.msg('deputy.session.page.pageonly.help'))),
padded: true
}
})));
}
const getButtonClickHandler = (button, reverse) => {
return () => __awaiter(this, void 0, void 0, function* () {
this.setDisabled(true);
if (this.options.nextRevision) {
// No need to worry about swapping elements here, since `loadNewDiff`
// will fire the `wikipage.diff` MW hook. This means this element will
// be rebuilt from scratch anyway.
try {
const nextRevisionData = yield window.deputy.comms.sendAndWait({
type: 'pageNextRevisionRequest',
caseId: this.options.caseId,
page: this.row.title.getPrefixedText(),
after: this.revision,
reverse
});
if (nextRevisionData == null) {
OO.ui.alert(mw.msg('deputy.session.page.incommunicable'));
this.setDisabled(false);
}
else if (nextRevisionData.revid != null) {
yield DiffPage.loadNewDiff(nextRevisionData.revid);
}
else {
this.setDisabled(false);
button.setDisabled(true);
}
}
catch (e) {
error(e);
this.setDisabled(false);
}
}
else if (this.options.nextRevision !== false) {
// Sets disabled to false if the value is null.
this.setDisabled(false);
}
});
};
this.previousRevisionButton = new OO.ui.ButtonWidget({
invisibleLabel: true,
label: mw.msg('deputy.session.page.diff.previous'),
title: mw.msg('deputy.session.page.diff.previous'),
icon: 'previous',
disabled: this.options.nextRevision == null
});
this.previousRevisionButton.on('click', getButtonClickHandler(this.nextRevisionButton, true));
this.nextRevisionButton = new OO.ui.ButtonWidget({
invisibleLabel: true,
label: mw.msg('deputy.session.page.diff.next'),
title: mw.msg('deputy.session.page.diff.next'),
icon: this.revision == null ? 'play' : 'next',
disabled: this.options.nextRevision == null
});
this.nextRevisionButton.on('click', getButtonClickHandler(this.nextRevisionButton, false));
return h_1("div", { class: "dp-pt-section" },
h_1("div", { class: "dp-pt-section-content" },
this.revision != null && unwrapWidget(this.previousRevisionButton),
unwrapWidget(this.nextRevisionButton)));
}
/**
* Renders a OOUI PopupButtonWidget and a menu, which contains a given set of
* menu options.
*
* @param label The label of the section
* @param options The section menu options
* @return The section HTML
*/
renderMenu(label, options) {
const popupButton = new OO.ui.ToggleButtonWidget({
label: label,
framed: false,
indicator: 'up'
});
return [
new DeputyPageMenu(options, this, popupButton).render(),
unwrapWidget(popupButton)
];
}
/**
* Renders the "Analysis" and "Tools" sections.
*
* @return The section HTML
*/
renderMenus() {
return h_1("div", { class: "dp-pt-section" },
h_1("div", { class: "dp-pt-section-content dp-pt-menu" },
this.renderMenu(mw.msg('deputy.session.page.analysis'), deputyPageAnalysisOptions()),
this.renderMenu(mw.msg('deputy.session.page.tools'), deputyPageTools())));
}
/**
* @inheritDoc
*/
render() {
return this.element = h_1("div", { class: "deputy dp-pageToolbar" },
this.renderStatusDropdown(),
this.renderCaseInfo(),
this.renderRevisionInfo(),
this.revisionNavigationSection =
this.renderRevisionNavigationButtons(),
this.renderMenus());
}
/**
* Sets the disabled state of the toolbar.
*
* @param disabled
*/
setDisabled(disabled) {
var _a, _b, _c, _d;
(_a = this.statusDropdown) === null || _a === void 0 ? void 0 : _a.setDisabled(disabled);
(_b = this.revisionCheckbox) === null || _b === void 0 ? void 0 : _b.setDisabled(disabled);
(_c = this.previousRevisionButton) === null || _c === void 0 ? void 0 : _c.setDisabled(disabled);
(_d = this.nextRevisionButton) === null || _d === void 0 ? void 0 : _d.setDisabled(disabled);
}
/**
* Performs cleanup and removes the element from the DOM.
*/
close() {
window.deputy.comms.removeEventListener('revisionStatusUpdate', this.revisionStatusUpdateListener);
this.statusDropdown.close();
removeElement(this.element);
}
/**
* Listener for revision status updates from the root session.
*
* @param root0
* @param root0.data
*/
onRevisionStatusUpdate({ data }) {
if (this.row.casePage.pageId === data.caseId &&
this.row.title.getPrefixedText() === data.page) {
if (this.revision === data.revision &&
this.revisionCheckbox.isSelected() !== data.status) {
this.revisionCheckbox.setSelected(data.status);
}
this.options.nextRevision = data.nextRevision;
// Re-render button.
swapElements(this.revisionNavigationSection, this.revisionNavigationSection =
this.renderRevisionNavigationButtons());
}
}
}
/**
* Controls everything related to a page that is the subject of an active
* Deputy row.
*/
class DeputyPageSession {
constructor() {
this.sessionCloseHandler = this.onSessionClosed.bind(this);
}
/**
* Attempts to grab page details from a session. If a session does not exist,
* this will return null.
*
* @param revision The revision of the page to get information for.
* If the page is being viewed normally (not in a diff or permanent link), then
* this value should be set to null. This ensures that a generic toolbar is
* used instead of the revision-specific toolbar.
* @param title The title of the page to get information for. Defaults to current.
* @param timeout Timeout for the page detail request.
*/
static getPageDetails(revision = mw.config.get('wgRevisionId'), title = window.deputy.currentPage, timeout = 500) {
return __awaiter(this, void 0, void 0, function* () {
return window.deputy.comms.sendAndWait({
type: 'pageStatusRequest',
page: title.getPrefixedText(),
revision: revision
}, timeout);
});
}
/**
*
* @param data
*/
init(data) {
window.deputy.comms.addEventListener('sessionClosed', this.sessionCloseHandler);
// Spawn toolbar
if (window.deputy.config.cci.enablePageToolbar.get()) {
mw.loader.using([
'oojs-ui-core',
'oojs-ui-windows',
'oojs-ui-widgets',
'oojs-ui.styles.icons-interactions',
'oojs-ui.styles.icons-movement',
'oojs-ui.styles.icons-moderation',
'oojs-ui.styles.icons-media',
'oojs-ui.styles.icons-editing-advanced',
'oojs-ui.styles.icons-editing-citation'
], () => {
if (mw.config.get('wgDiffNewId') === null) {
// Not on a diff page, but wgRevisionId is populated nonetheless.
this.initInterface(data);
}
else {
mw.hook('wikipage.diff').add(() => __awaiter(this, void 0, void 0, function* () {
yield this.initInterface(data);
}));
}
});
}
}
/**
* Initialize the interface.
*
* @param data
*/
initInterface(data) {
return __awaiter(this, void 0, void 0, function* () {
// Attempt to get new revision data *with revision ID*.
const isCurrentDiff = /[?&]diff=0+(\D|$)/.test(window.location.search);
data = yield DeputyPageSession.getPageDetails((isCurrentDiff ?
// On a "cur" diff page
mw.config.get('wgDiffOldId') :
// On a "prev" diff page
mw.config.get('wgDiffNewId')) ||
mw.config.get('wgRevisionId'), window.deputy.currentPage,
// Relatively low-stakes branch, we can handle a bit of a delay.
2000);
const openPromise = this.appendToolbar(Object.assign(Object.assign({}, data), { forceRevision: this.toolbar != null ||
// Is a diff page.
mw.config.get('wgDiffNewId') != null }));
if (
// Previous toolbar exists. Close it before moving on.
this.toolbar &&
this.toolbar.revision !== mw.config.get('wgRevisionId')) {
const oldToolbar = this.toolbar;
openPromise.then(() => {
oldToolbar.close();
});
}
this.toolbar = yield openPromise;
});
}
/**
* Creates the Deputy page toolbar and appends it to the DOM.
*
* @param data Data for constructing the toolbar
*/
appendToolbar(data) {
return __awaiter(this, void 0, void 0, function* () {
const toolbar = new DeputyPageToolbar(data);
yield toolbar.prepare();
document.getElementsByTagName('body')[0]
.appendChild(toolbar.render());
return toolbar;
});
}
/**
* Cleanup toolbar, remove event listeners, and remove from DOM.
*/
close() {
var _a;
(_a = this.toolbar) === null || _a === void 0 ? void 0 : _a.close();
window.deputy.comms.removeEventListener('sessionClosed', this.sessionCloseHandler);
}
/**
* Handler for when a session is closed.
*/
onSessionClosed() {
this.close();
}
}
/**
* Handles the active Deputy session.
*
* A "Session" is a period wherein Deputy exercises a majority of its features,
* namely the use of inter-tab communication and database transactions for
* page and revision caching. Other tabs that load Deputy will recognize the
* started session and begin communicating with the root tab (the tab with the
* CCI page, and therefore the main Deputy session handler, open). The handler
* for root tab session activities is {@link DeputyRootSession}.
*/
class DeputySession {
constructor() {
this.DeputyRootSession = DeputyRootSession;
this.DeputyPageSession = DeputyPageSession;
}
/**
* Initialize session-related information. If an active session was detected,
* restart it.
*/
init() {
return __awaiter(this, void 0, void 0, function* () {
// Check if there is an active session.
const session = yield this.getSession();
// Ensure wiki config is loaded
const wikiConfig = yield window.deputy.getWikiConfig();
if (!wikiConfig.cci.enabled.get() || wikiConfig.cci.rootPage.get() == null) {
// Not configured. Exit.
return;
}
if (session) {
const viewingCurrent =
// Page is being viewed.
mw.config.get('wgAction') === 'view' &&
// Revision is current revision. Also handles wgRevisionId = 0
// (which happens when viewing a diff).
mw.config.get('wgRevisionId') ===
mw.config.get('wgCurRevisionId');
if (session.caseSections.length === 0) {
// No more sections. Discard session.
yield this.clearSession();
yield this.init();
}
else if (session.casePageId === window.deputy.currentPageId) {
// Definitely a case page, no need to question.
const casePage = yield DeputyCasePage.build();
this.rootSession = new DeputyRootSession(session, casePage);
if (viewingCurrent && (yield this.checkForActiveSessionTabs())) {
// Session is active in another tab. Defer to other tab.
yield DeputyRootSession.initTabActiveInterface(casePage);
}
else if (viewingCurrent) {
// Page reloaded or exited without proper session close.
// Continue where we left off.
yield this.rootSession.initSessionInterface();
yield casePage.bumpActive();
}
}
else if (DeputyCasePage.isCasePage()) {
if (mw.config.get('wgCurRevisionId') !==
mw.config.get('wgRevisionId')) {
// This is an old revision. Don't show the interface.
return;
}
const casePage = yield DeputyCasePage.build();
yield DeputyRootSession.initOverwriteMessage(casePage);
}
else if (mw.config.get('wgAction') === 'view') {
yield this.normalPageInitialization();
window.deputy.comms.addEventListener('sessionStarted', () => {
// This misses by a few seconds right now since sessionStarted is
// called when the sessionStarts but not when it is ready.
// TODO: Fix that.
this.normalPageInitialization();
});
}
}
else {
// No active session
if (DeputyCasePage.isCasePage()) {
const casePage = yield DeputyCasePage.build();
if (yield casePage.isCached()) {
yield DeputyRootSession.initContinueInterface(casePage);
}
else {
// Show session start buttons
yield DeputyRootSession.initEntryInterface(casePage);
}
}
}
});
}
/**
* Broadcasts a `sessionRequest` message to the Deputy communicator to find other
* tabs with open sessions. This prevents two tabs from opening the same session
* at the same time.
*/
checkForActiveSessionTabs() {
return __awaiter(this, void 0, void 0, function* () {
return yield window.deputy.comms.sendAndWait({ type: 'sessionRequest' })
.then((res) => {
return res != null;
});
});
}
/**
* Detects if a session is currently active, attempt to get page details, and
* start a page session if details have been found.
*
* @return `true` if a session was started, `false` otherwise.
*/
normalPageInitialization() {
return __awaiter(this, void 0, void 0, function* () {
// Normal page. Determine if this is being worked on, and then
// start a new session if it is.
const pageSession = yield DeputyPageSession.getPageDetails();
if (pageSession) {
// This page is being worked on, create a session.
this.pageSession = new DeputyPageSession();
this.pageSession.init(pageSession);
return true;
}
else {
return false;
}
});
}
/**
* Gets the current active session information. Session mutation functions (besides
* `clearSession`) are only available in {@link DeputyRootSession}.
*
* @return {Promise<SessionInformation|undefined>}
* A promise that resolves with the session information or `undefined` if session
* information is not available.
*/
getSession() {
return __awaiter(this, void 0, void 0, function* () {
return (yield window.deputy.storage.getKV('session'));
});
}
/**
* Sets the current active session information.
*
* @return boolean `true` if successful.
*/
clearSession() {
if (this.rootSession) {
this.rootSession.session = null;
}
return window.deputy.storage.setKV('session', null);
}
}
/**
* MediaWiki core contains a lot of quirks in the code. Other extensions
* also have their own quirks. To prevent these quirks from affecting Deputy's
* functionality, we need to perform a few hacks.
*/
function performHacks () {
var _a;
const HtmlEmitter = (_a = mw.jqueryMsg.HtmlEmitter) !== null && _a !== void 0 ? _a : {
prototype: Object.getPrototypeOf(new mw.jqueryMsg.Parser().emitter)
};
// This applies the {{int:message}} parser function with "MediaWiki:". This
// is due to VisualEditor using "MediaWiki:" in message values instead of "int:"
HtmlEmitter.prototype.mediawiki =
HtmlEmitter.prototype.int;
/**
* Performs a simple if check. Works just like the Extension:ParserFunctions
* version; it checks if the first parameter is blank and returns the second
* parameter if true. The latter parameter is passed if false.
*
* UNLIKE the Extension:ParserFunctions version, this version does not trim
* the parameters.
*
* @see https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions#if
* @param nodes
* @return see function description
*/
HtmlEmitter.prototype.if = function (nodes) {
var _a, _b;
return (nodes[0].trim() ? ((_a = nodes[1]) !== null && _a !== void 0 ? _a : '') : ((_b = nodes[2]) !== null && _b !== void 0 ? _b : ''));
};
// "#if" is unsupported due to the parsing done by jqueryMsg.
/**
* Simple function to avoid parsing errors during message expansion. Drops the "Template:"
* prefix before a link.
*
* @param nodes
* @return `{{text}}`
*/
HtmlEmitter.prototype.template = function (nodes) {
return `{{${nodes.join('|')}}}`;
};
/**
* Allows `{{subst:...}}` to work. Does not actually change anything.
*
* @param nodes
* @return `{{text}}`
*/
HtmlEmitter.prototype.subst = function (nodes) {
return `{{subst:${nodes.map((v) => typeof v === 'string' ? v : v.text()).join('|')}}}`;
};
/**
* Works exactly like the localurl magic word. Returns the local href to a page.
* Also adds query strings if given.
*
* @see https://www.mediawiki.org/wiki/Help:Magic_words#URL_data
* @param nodes
* @return `/wiki/{page}?{query}`
*/
HtmlEmitter.prototype.localurl = function (nodes) {
return mw.util.getUrl(nodes[0]) + '?' + nodes[1];
};
}
/**
* Copies text to the clipboard. Relies on the old style of clipboard copying
* (using `document.execCommand` due to a lack of support for `navigator`-based
* clipboard handling).
*
* @param text The text to copy to the clipboard.
*/
function copyToClipboard (text) {
const body = document.getElementsByTagName('body')[0];
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-100vw';
textarea.style.top = '-100vh';
body.appendChild(textarea);
textarea.select();
// noinspection JSDeprecatedSymbols
document.execCommand('copy');
body.removeChild(textarea);
}
/**
* Performs {{yesno}}-based string interpretation.
*
* @param value The value to check
* @param pull Depends which direction to pull unspecified values.
* @return If `pull` is true,
* any value that isn't explicitly a negative value will return `true`. Otherwise,
* any value that isn't explicitly a positive value will return `false`.
*/
function yesNo (value, pull = true) {
if (pull) {
return value !== false &&
value !== 'no' &&
value !== 'n' &&
value !== 'false' &&
value !== 'f' &&
value !== 'off' &&
+value !== 0;
}
else {
return !(value !== true &&
value !== 'yes' &&
value !== 'y' &&
value !== 't' &&
value !== 'true' &&
value !== 'on' &&
+value !== 1);
}
}
let InternalRevisionDateGetButton;
/**
* Initializes the process element.
*/
function initRevisionDateGetButton() {
InternalRevisionDateGetButton = class RevisionDateGetButton extends OO.ui.ButtonWidget {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
super(Object.assign({
icon: 'download',
invisibleLabel: true,
disabled: true
}, config));
this.revisionInputWidget = config.revisionInputWidget;
this.dateInputWidget = config.dateInputWidget;
this.revisionInputWidget.on('change', this.updateButton.bind(this));
this.dateInputWidget.on('change', this.updateButton.bind(this));
this.on('click', this.setDateFromRevision.bind(this));
this.updateButton();
}
/**
* Update the disabled state of the button.
*/
updateButton() {
this.setDisabled(isNaN(+this.revisionInputWidget.getValue()) ||
!!this.dateInputWidget.getValue());
}
/**
* Set the date from the revision ID provided in the value of
* `this.revisionInputWidget`.
*/
setDateFromRevision() {
return __awaiter(this, void 0, void 0, function* () {
const revid = this.revisionInputWidget.getValue();
if (isNaN(+revid)) {
mw.notify(mw.msg('deputy.ante.dateAuto.invalid'), { type: 'error' });
this.updateButton();
return;
}
this
.setIcon('ellipsis')
.setDisabled(true);
this.dateInputWidget.setDisabled(true);
yield MwApi.action.get({
action: 'query',
prop: 'revisions',
revids: revid,
rvprop: 'timestamp'
}).then((data) => {
if (data.query.badrevids != null) {
mw.notify(mw.msg('deputy.ante.dateAuto.missing', revid), { type: 'error' });
this.updateButton();
return;
}
this.dateInputWidget.setValue(
// ISO-format date
data.query.pages[0].revisions[0].timestamp.split('T')[0]);
this.dateInputWidget.setDisabled(false);
this.setIcon('download');
this.updateButton();
}, (_error, errorData) => {
mw.notify(mw.msg('deputy.ante.dateAuto.failed', getApiErrorText(errorData)), { type: 'error' });
this.dateInputWidget.setDisabled(false);
this.setIcon('download');
this.updateButton();
});
});
}
};
}
/**
* Creates a new RevisionDateGetButton.
*
* @param config Configuration to be passed to the element.
* @return A RevisionDateGetButton object
*/
function RevisionDateGetButton (config) {
if (!InternalRevisionDateGetButton) {
initRevisionDateGetButton();
}
return new InternalRevisionDateGetButton(config);
}
let InternalSmartTitleInputWidget;
/**
* Initializes the process element.
*/
function initSmartTitleInputWidget() {
InternalSmartTitleInputWidget = class SmartTitleInputWidget extends mw.widgets.TitleInputWidget {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
super(Object.assign(config, {
// Force this to be true
allowSuggestionsWhenEmpty: true
}));
}
/**
* @inheritDoc
*/
getRequestQuery() {
const v = super.getRequestQuery();
return v || normalizeTitle().getSubjectPage().getPrefixedText();
}
/**
* @inheritDoc
*/
getQueryValue() {
const v = super.getQueryValue();
return v || normalizeTitle().getSubjectPage().getPrefixedText();
}
};
}
/**
* Creates a new SmartTitleInputWidget.
*
* @param config Configuration to be passed to the element.
* @return A SmartTitleInputWidget object
*/
function SmartTitleInputWidget (config) {
if (!InternalSmartTitleInputWidget) {
initSmartTitleInputWidget();
}
return new InternalSmartTitleInputWidget(config);
}
let InternalPageLatestRevisionGetButton;
/**
* Initializes the process element.
*/
function initPageLatestRevisionGetButton() {
InternalPageLatestRevisionGetButton = class PageLatestRevisionGetButton extends OO.ui.ButtonWidget {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
super(Object.assign({
icon: 'download',
invisibleLabel: true,
disabled: true
}, config));
this.titleInputWidget = config.titleInputWidget;
this.revisionInputWidget = config.revisionInputWidget;
this.titleInputWidget.on('change', this.updateButton.bind(this));
this.revisionInputWidget.on('change', this.updateButton.bind(this));
this.on('click', this.setRevisionFromPageLatestRevision.bind(this));
this.updateButton();
}
/**
* Update the disabled state of the button.
*/
updateButton() {
this.setDisabled(this.titleInputWidget.getValue().trim().length === 0 ||
this.revisionInputWidget.getValue().trim().length !== 0 ||
!this.titleInputWidget.isQueryValid());
}
/**
* Set the revision ID from the page provided in the value of
* `this.titleInputWidget`.
*/
setRevisionFromPageLatestRevision() {
return __awaiter(this, void 0, void 0, function* () {
this
.setIcon('ellipsis')
.setDisabled(true);
this.revisionInputWidget.setDisabled(true);
const title = this.titleInputWidget.getValue();
yield MwApi.action.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'ids'
}).then((data) => {
if (data.query.pages[0].missing) {
mw.notify(mw.msg('deputy.ante.revisionAuto.missing', title), { type: 'error' });
this.updateButton();
return;
}
this.revisionInputWidget.setValue(data.query.pages[0].revisions[0].revid);
this.revisionInputWidget.setDisabled(false);
this.setIcon('download');
this.updateButton();
}, (_error, errorData) => {
mw.notify(mw.msg('deputy.ante.revisionAuto.failed', getApiErrorText(errorData)), { type: 'error' });
this.revisionInputWidget.setDisabled(false);
this.setIcon('download');
this.updateButton();
});
});
}
};
}
/**
* Creates a new PageLatestRevisionGetButton.
*
* @param config Configuration to be passed to the element.
* @return A PageLatestRevisionGetButton object
*/
function PageLatestRevisionGetButton (config) {
if (!InternalPageLatestRevisionGetButton) {
initPageLatestRevisionGetButton();
}
return new InternalPageLatestRevisionGetButton(config);
}
let InternalCopiedTemplateRowPage;
/**
* The UI representation of a {{copied}} template row. This refers to a set of `diff`, `to`,
* or `from` parameters on each {{copied}} template.
*
* Note that "Page" in the class title does not refer to a MediaWiki page, but rather
* a OOUI PageLayout.
*/
function initCopiedTemplateRowPage() {
InternalCopiedTemplateRowPage = class CopiedTemplateRowPage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { copiedTemplateRow, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (copiedTemplateRow == null) {
throw new Error('Reference row (CopiedTemplateRow) is required');
}
const finalConfig = {
classes: ['cte-page-row']
};
super(copiedTemplateRow.id, finalConfig);
this.parent = parent;
this.copiedTemplateRow = copiedTemplateRow;
this.refreshLabel();
this.copiedTemplateRow.parent.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.copiedTemplateRow.parent.addEventListener('rowDelete', () => {
parent.rebuildPages();
});
this.$element.append(this.render().$element);
}
/**
* Refreshes the page's label
*/
refreshLabel() {
if (this.copiedTemplateRow.from && equalTitle(this.copiedTemplateRow.from, normalizeTitle(this.copiedTemplateRow.parent.parsoid.getPage())
.getSubjectPage())) {
this.label = mw.message('deputy.ante.copied.entry.shortTo', this.copiedTemplateRow.to || '???').text();
}
else if (this.copiedTemplateRow.to && equalTitle(this.copiedTemplateRow.to, normalizeTitle(this.copiedTemplateRow.parent.parsoid.getPage())
.getSubjectPage())) {
this.label = mw.message('deputy.ante.copied.entry.shortFrom', this.copiedTemplateRow.from || '???').text();
}
else {
this.label = mw.message('deputy.ante.copied.entry.short', this.copiedTemplateRow.from || '???', this.copiedTemplateRow.to || '???').text();
}
if (this.outlineItem) {
this.outlineItem.setLabel(this.label);
}
}
/**
* Renders this page. Returns a FieldsetLayout OOUI widget.
*
* @return An OOUI FieldsetLayout
*/
render() {
this.layout = new OO.ui.FieldsetLayout({
icon: 'parameter',
label: mw.msg('deputy.ante.copied.entry.label'),
classes: ['cte-fieldset']
});
this.layout.$element.append(this.renderButtons());
this.layout.addItems(this.renderFields());
return this.layout;
}
/**
* Renders a set of buttons used to modify a specific {{copied}} template row.
*
* @return An array of OOUI FieldLayouts
*/
renderButtons() {
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.copied.entry.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
this.copiedTemplateRow.parent.deleteRow(this.copiedTemplateRow);
});
const copyButton = new OO.ui.ButtonWidget({
icon: 'quotes',
title: mw.msg('deputy.ante.copied.entry.copy'),
framed: false
});
copyButton.on('click', () => {
// TODO: Find out a way to l10n-ize this.
let attributionString = `[[WP:PATT|Attribution]]: Content ${this.copiedTemplateRow.merge ? 'merged' : 'partially copied'}`;
let lacking = false;
if (this.copiedTemplateRow.from != null &&
this.copiedTemplateRow.from.length !== 0) {
attributionString += ` from [[${this.copiedTemplateRow.from}]]`;
}
else {
lacking = true;
if (this.copiedTemplateRow.from_oldid != null) {
attributionString += ' from a page';
}
}
if (this.copiedTemplateRow.from_oldid != null) {
attributionString += ` as of revision [[Special:Diff/${this.copiedTemplateRow.from_oldid}|${this.copiedTemplateRow.from_oldid}]]`;
}
if (this.copiedTemplateRow.to_diff != null ||
this.copiedTemplateRow.to_oldid != null) {
// Shifting will ensure that `to_oldid` will be used if `to_diff` is missing.
const diffPart1 = this.copiedTemplateRow.to_oldid ||
this.copiedTemplateRow.to_diff;
const diffPart2 = this.copiedTemplateRow.to_diff ||
this.copiedTemplateRow.to_oldid;
attributionString += ` with [[Special:Diff/${diffPart1 === diffPart2 ? diffPart1 : `${diffPart1}/${diffPart2}`}|this edit]]`;
}
if (this.copiedTemplateRow.from != null &&
this.copiedTemplateRow.from.length !== 0) {
attributionString += `; refer to that page's [[Special:PageHistory/${this.copiedTemplateRow.from}|edit history]] for additional attribution`;
}
attributionString += '.';
copyToClipboard(attributionString);
if (lacking) {
mw.notify(mw.msg('deputy.ante.copied.entry.copy.lacking'), { title: mw.msg('deputy.ante'), type: 'warn' });
}
else {
mw.notify(mw.msg('deputy.ante.copied.entry.copy.success'), { title: mw.msg('deputy.ante') });
}
});
return h_1("div", { style: {
float: 'right',
position: 'absolute',
top: '0.5em',
right: '0.5em'
} },
unwrapWidget(copyButton),
unwrapWidget(deleteButton));
}
/**
* Renders a set of OOUI InputWidgets and FieldLayouts, eventually returning an
* array of each FieldLayout to append to the FieldsetLayout.
*
* @return An array of OOUI FieldLayouts
*/
renderFields() {
const copiedTemplateRow = this.copiedTemplateRow;
const parsedDate = (copiedTemplateRow.date == null || copiedTemplateRow.date.trim().length === 0) ?
undefined : (!isNaN(new Date(copiedTemplateRow.date.trim() + ' UTC').getTime()) ?
(new Date(copiedTemplateRow.date.trim() + ' UTC')) : (!isNaN(new Date(copiedTemplateRow.date.trim()).getTime()) ?
new Date(copiedTemplateRow.date.trim()) : null));
this.inputs = {
from: SmartTitleInputWidget({
$overlay: this.parent.$overlay,
placeholder: mw.msg('deputy.ante.copied.from.placeholder'),
value: copiedTemplateRow.from,
validate: /^.+$/g
}),
from_oldid: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.copied.from_oldid.placeholder'),
value: copiedTemplateRow.from_oldid,
validate: /^\d*$/
}),
to: SmartTitleInputWidget({
$overlay: this.parent.$overlay,
placeholder: mw.msg('deputy.ante.copied.to.placeholder'),
value: copiedTemplateRow.to
}),
to_diff: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.copied.to_diff.placeholder'),
value: copiedTemplateRow.to_diff,
validate: /^\d*$/
}),
// Advanced options
to_oldid: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.copied.to_oldid.placeholder'),
value: copiedTemplateRow.to_oldid,
validate: /^\d*$/
}),
diff: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.copied.diff.placeholder'),
value: copiedTemplateRow.diff
}),
merge: new OO.ui.CheckboxInputWidget({
selected: yesNo(copiedTemplateRow.merge)
}),
afd: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.copied.afd.placeholder'),
value: copiedTemplateRow.afd,
disabled: copiedTemplateRow.merge === undefined,
// Prevent people from adding the WP:AFD prefix.
validate: /^((?!W(iki)?p(edia)?:(A(rticles)?[ _]?f(or)?[ _]?d(eletion)?\/)).+|$)/gi
}),
date: new mw.widgets.DateInputWidget({
$overlay: this.parent.$overlay,
icon: 'calendar',
value: parsedDate ? `${parsedDate.getUTCFullYear()}-${parsedDate.getUTCMonth() + 1}-${parsedDate.getUTCDate()}` : undefined,
placeholder: mw.msg('deputy.ante.copied.date.placeholder'),
calendar: {
verticalPosition: 'above'
}
}),
toggle: new OO.ui.ToggleSwitchWidget()
};
const diffConvert = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ante.copied.convert')
});
const dateAuto = RevisionDateGetButton({
label: mw.msg('deputy.ante.dateAuto', 'to_diff'),
revisionInputWidget: this.inputs.to_diff,
dateInputWidget: this.inputs.date
});
const revisionAutoFrom = PageLatestRevisionGetButton({
invisibleLabel: false,
label: mw.msg('deputy.ante.revisionAuto'),
title: mw.msg('deputy.ante.revisionAuto.title', 'from'),
titleInputWidget: this.inputs.from,
revisionInputWidget: this.inputs.from_oldid
});
const revisionAutoTo = PageLatestRevisionGetButton({
invisibleLabel: false,
label: mw.msg('deputy.ante.revisionAuto'),
title: mw.msg('deputy.ante.revisionAuto.title', 'to'),
titleInputWidget: this.inputs.to,
revisionInputWidget: this.inputs.to_diff
});
this.fieldLayouts = {
from: new OO.ui.FieldLayout(this.inputs.from, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.from.label'),
align: 'top',
help: mw.msg('deputy.ante.copied.from.help')
}),
from_oldid: new OO.ui.ActionFieldLayout(this.inputs.from_oldid, revisionAutoFrom, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.from_oldid.label'),
align: 'left',
help: mw.msg('deputy.ante.copied.from_oldid.help')
}),
to: new OO.ui.FieldLayout(this.inputs.to, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.to.label'),
align: 'top',
help: mw.msg('deputy.ante.copied.to.help')
}),
to_diff: new OO.ui.ActionFieldLayout(this.inputs.to_diff, revisionAutoTo, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.to_diff.label'),
align: 'left',
help: mw.msg('deputy.ante.copied.to_diff.help')
}),
// Advanced options
to_oldid: new OO.ui.FieldLayout(this.inputs.to_oldid, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.to_oldid.label'),
align: 'left',
help: mw.msg('deputy.ante.copied.to_oldid.help')
}),
diff: new OO.ui.ActionFieldLayout(this.inputs.diff, diffConvert, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.diff.label'),
align: 'inline',
help: new OO.ui.HtmlSnippet(mw.message('deputy.ante.copied.diff.help').plain())
}),
merge: new OO.ui.FieldLayout(this.inputs.merge, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.merge.label'),
align: 'inline',
help: mw.msg('deputy.ante.copied.merge.help')
}),
afd: new OO.ui.FieldLayout(this.inputs.afd, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.copied.afd.label'),
align: 'left',
help: mw.msg('deputy.ante.copied.afd.help')
}),
date: new OO.ui.ActionFieldLayout(this.inputs.date, dateAuto, {
align: 'inline',
classes: ['cte-fieldset-date']
}),
toggle: new OO.ui.FieldLayout(this.inputs.toggle, {
label: mw.msg('deputy.ante.copied.advanced'),
align: 'inline',
classes: ['cte-fieldset-advswitch']
})
};
if (parsedDate === null) {
this.fieldLayouts.date.setWarnings([
mw.msg('deputy.ante.copied.dateInvalid', copiedTemplateRow.date)
]);
}
// Define options that get hidden when advanced options are toggled
const advancedOptions = [
this.fieldLayouts.to_oldid,
this.fieldLayouts.diff,
this.fieldLayouts.merge,
this.fieldLayouts.afd
];
// Self-imposed deprecation notice in order to steer away from plain URL
// diff links. This will, in the long term, make it easier to parse out
// and edit {{copied}} templates.
const diffDeprecatedNotice = new OO.ui.HtmlSnippet(mw.message('deputy.ante.copied.diffDeprecate').plain());
// Hide advanced options
advancedOptions.forEach((e) => {
e.toggle(false);
});
// ...except for `diff` if it's supplied (legacy reasons)
if (copiedTemplateRow.diff) {
this.fieldLayouts.diff.toggle(true);
this.fieldLayouts.diff.setWarnings([diffDeprecatedNotice]);
}
else {
diffConvert.setDisabled(true);
}
// Attach event listeners
this.inputs.diff.on('change', () => {
if (this.inputs.diff.getValue().length > 0) {
try {
// Check if the diff URL is from this wiki.
if (new URL(this.inputs.diff.getValue(), window.location.href).host === window.location.host) {
// Prefer `to_oldid` and `to_diff`
this.fieldLayouts.diff.setWarnings([diffDeprecatedNotice]);
diffConvert.setDisabled(false);
}
else {
this.fieldLayouts.diff.setWarnings([]);
diffConvert.setDisabled(true);
}
}
catch (e) {
// Clear warnings just to be safe.
this.fieldLayouts.diff.setWarnings([]);
diffConvert.setDisabled(true);
}
}
else {
this.fieldLayouts.diff.setWarnings([]);
diffConvert.setDisabled(true);
}
});
this.inputs.merge.on('change', (value) => {
this.inputs.afd.setDisabled(!value);
});
this.inputs.toggle.on('change', (value) => {
advancedOptions.forEach((e) => {
e.toggle(value);
});
this.fieldLayouts.to_diff.setLabel(value ? 'Ending revision ID' : 'Revision ID');
});
this.inputs.from.on('change', () => {
this.refreshLabel();
});
this.inputs.to.on('change', () => {
this.refreshLabel();
});
for (const _field in this.inputs) {
if (_field === 'toggle') {
continue;
}
const field = _field;
const input = this.inputs[field];
// Attach the change listener
input.on('change', (value) => {
if (input instanceof OO.ui.CheckboxInputWidget) {
// Specific to `merge`. Watch out before adding more checkboxes.
this.copiedTemplateRow[field] = value ? 'yes' : '';
}
else if (input instanceof mw.widgets.DateInputWidget) {
this.copiedTemplateRow[field] = value ?
window.moment(value, 'YYYY-MM-DD')
.locale(mw.config.get('wgContentLanguage'))
.format('D MMMM Y') : undefined;
if (value.length > 0) {
this.fieldLayouts[field].setWarnings([]);
}
}
else {
this.copiedTemplateRow[field] = value;
}
copiedTemplateRow.parent.save();
this.refreshLabel();
});
if (input instanceof OO.ui.TextInputWidget) {
// Rechecks the validity of the field.
input.setValidityFlag();
}
}
// Diff convert click handler
diffConvert.on('click', this.convertDeprecatedDiff.bind(this));
return getObjectValues(this.fieldLayouts);
}
/**
* Converts a raw diff URL on the same wiki as the current to use `to` and `to_oldid`
* (and `to_diff`, if available).
*/
convertDeprecatedDiff() {
return __awaiter(this, void 0, void 0, function* () {
const value = this.inputs.diff.getValue();
try {
const url = new URL(value, window.location.href);
if (!value) {
return;
}
if (url.host !== window.location.host) {
if (!(yield OO.ui.confirm(mw.msg('deputy.ante.copied.diffDeprecate.warnHost')))) {
return;
}
}
// From the same wiki, accept deprecation immediately.
// Parse out info from this diff URL
const parseInfo = parseDiffUrl(url);
let { diff, oldid } = parseInfo;
const { title } = parseInfo;
// If only an oldid was provided, and no diff
if (oldid && !diff) {
diff = oldid;
oldid = undefined;
}
const confirmProcess = new OO.ui.Process();
// Looping over the row name and the value that will replace it.
for (const [_rowName, newValue] of [
['to_oldid', oldid],
['to_diff', diff],
['to', title]
]) {
const rowName = _rowName;
if (newValue == null) {
continue;
}
if (
// Field has an existing value
this.copiedTemplateRow[rowName] != null &&
this.copiedTemplateRow[rowName].length > 0 &&
this.copiedTemplateRow[rowName] !== newValue) {
confirmProcess.next(() => __awaiter(this, void 0, void 0, function* () {
const confirmPromise = dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.copied.diffDeprecate.replace', rowName, this.copiedTemplateRow[rowName], newValue).text());
confirmPromise.done((confirmed) => {
if (confirmed) {
this.inputs[rowName].setValue(newValue);
this.fieldLayouts[rowName].toggle(true);
}
});
return confirmPromise;
}));
}
else {
this.inputs[rowName].setValue(newValue);
this.fieldLayouts[rowName].toggle(true);
}
}
confirmProcess.next(() => {
this.copiedTemplateRow.parent.save();
this.inputs.diff.setValue('');
if (!this.inputs.toggle.getValue()) {
this.fieldLayouts.diff.toggle(false);
}
});
confirmProcess.execute();
}
catch (e) {
error('Cannot convert `diff` parameter to URL.', e);
OO.ui.alert(mw.msg('deputy.ante.copied.diffDeprecate.failed'));
}
});
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('parameter')
.setLevel(1)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new CopiedTemplateRowPage.
*
* @param config Configuration to be passed to the element.
* @return A CopiedTemplateRowPage object
*/
function CopiedTemplateRowPage (config) {
if (!InternalCopiedTemplateRowPage) {
initCopiedTemplateRowPage();
}
return new InternalCopiedTemplateRowPage(config);
}
/**
* An attribution notice's row or entry.
*/
class AttributionNoticeRow {
/**
*
* @param parent
*/
constructor(parent) {
this._parent = parent;
const r = window.btoa((Math.random() * 10000).toString()).slice(0, 6);
this.name = this.parent.name + '#' + r;
this.id = window.btoa(parent.node.getTarget().wt) + '-' + this.name;
}
/**
* @return The parent of this attribution notice row.
*/
get parent() {
return this._parent;
}
/**
* Sets the parent. Automatically moves this template from one
* parent's row set to another.
*
* @param newParent The new parent.
*/
set parent(newParent) {
this._parent.deleteRow(this);
newParent.addRow(this);
this._parent = newParent;
}
/**
* Clones this row.
*
* @param parent The parent of this new row.
* @return The cloned row
*/
clone(parent) {
// Odd constructor usage here allows cloning from subclasses without having
// to re-implement the cloning function.
// noinspection JSCheckFunctionSignatures
return new this.constructor(this, parent);
}
}
const copiedTemplateRowParameters = [
'from', 'from_oldid', 'to', 'to_diff',
'to_oldid', 'diff', 'date', 'afd', 'merge'
];
/**
* Represents a row/entry in a {{copied}} template.
*/
class CopiedTemplateRow extends AttributionNoticeRow {
// noinspection JSDeprecatedSymbols
/**
* Creates a new RawCopiedTemplateRow
*
* @param rowObjects
* @param parent
*/
constructor(rowObjects, parent) {
super(parent);
this.from = rowObjects.from;
// eslint-disable-next-line camelcase
this.from_oldid = rowObjects.from_oldid;
this.to = rowObjects.to;
// eslint-disable-next-line camelcase
this.to_diff = rowObjects.to_diff;
// eslint-disable-next-line camelcase
this.to_oldid = rowObjects.to_oldid;
this.diff = rowObjects.diff;
this.date = rowObjects.date;
this.afd = rowObjects.afd;
this.merge = rowObjects.merge;
}
/**
* @inheritDoc
*/
clone(parent) {
return super.clone(parent);
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return CopiedTemplateRowPage({
copiedTemplateRow: this,
parent: dialog
});
}
}
/**
* Merges templates together. Its own class to avoid circular dependencies.
*/
class TemplateMerger {
/**
* Merge an array of CopiedTemplates into one big CopiedTemplate. Other templates
* will be destroyed.
*
* @param templateList The list of templates to merge
* @param pivot The template to merge into. If not supplied, the first template
* in the list will be used.
*/
static merge(templateList, pivot) {
pivot = pivot !== null && pivot !== void 0 ? pivot : templateList[0];
while (templateList.length > 0) {
const template = templateList[0];
if (template !== pivot) {
if (template.node.getTarget().href !== pivot.node.getTarget().href) {
throw new Error("Attempted to merge incompatible templates.");
}
pivot.merge(template, { delete: true });
}
// Pop the pivot template out of the list.
templateList.shift();
}
}
}
/**
* Renders the panel used to merge multiple {{split article}} templates.
*
* @param type
* @param parentTemplate
* @param mergeButton
* @return A <div> element
*/
function renderMergePanel(type, parentTemplate, mergeButton) {
const mergePanel = new OO.ui.FieldsetLayout({
classes: ['cte-merge-panel'],
icon: 'tableMergeCells',
label: mw.msg('deputy.ante.merge.title')
});
unwrapWidget(mergePanel).style.padding = '16px';
unwrapWidget(mergePanel).style.zIndex = '20';
// Hide by default
mergePanel.toggle(false);
// <select> and button for merging templates
const mergeTarget = new OO.ui.DropdownInputWidget({
$overlay: true,
title: mw.msg('deputy.ante.merge.from.select')
});
const mergeTargetButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ante.merge.button')
});
mergeTargetButton.on('click', () => {
const template = parentTemplate.parsoid.findNoticeType(type).find((v) => v.name === mergeTarget.getValue());
if (template) {
// If template found, merge and reset panel
parentTemplate.merge(template, { delete: true });
mergeTarget.setValue(null);
mergePanel.toggle(false);
}
});
const mergeFieldLayout = new OO.ui.ActionFieldLayout(mergeTarget, mergeTargetButton, {
label: mw.msg('deputy.ante.merge.from.label'),
align: 'left'
});
mergeButton.on('click', () => {
mergePanel.toggle();
});
const mergeAllButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ante.merge.all'),
flags: ['progressive']
});
mergeAllButton.on('click', () => {
const notices = parentTemplate.parsoid.findNoticeType(type);
// Confirm before merging.
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.merge.all.confirm', `${notices.length - 1}`).text()).done((confirmed) => {
if (confirmed) {
// Recursively merge all templates
TemplateMerger.merge(notices, parentTemplate);
mergeTarget.setValue(null);
mergePanel.toggle(false);
}
});
});
const recalculateOptions = () => {
const notices = parentTemplate.parsoid.findNoticeType(type);
const options = [];
for (const notice of notices) {
if (notice === parentTemplate) {
continue;
}
options.push({
data: notice.name,
// Messages used here:
// * deputy.ante.copied.label
// * deputy.ante.splitArticle.label
// * deputy.ante.mergedFrom.label
// * deputy.ante.mergedTo.label
// * deputy.ante.backwardsCopy.label
// * deputy.ante.translatedPage.label
label: mw.message(`deputy.ante.${type}.label`, notice.name).text()
});
}
if (options.length === 0) {
options.push({
data: null,
label: mw.msg('deputy.ante.merge.from.empty'),
disabled: true
});
mergeTargetButton.setDisabled(true);
mergeAllButton.setDisabled(true);
}
else {
mergeTargetButton.setDisabled(false);
mergeAllButton.setDisabled(false);
}
mergeTarget.setOptions(options);
};
mergePanel.on('toggle', recalculateOptions);
mergePanel.addItems([mergeFieldLayout, mergeAllButton]);
return unwrapWidget(mergePanel);
}
/**
* Renders the preview "panel". Not an actual panel, but rather a <div> that
* shows a preview of the template to be saved. Automatically updates on
* template changes.
*
* @param template The notice to generate previews for and listen events on.
* @return A preview panel that automatically updates based on the provided notice.
*/
function renderPreviewPanel(template) {
const previewPanel = h_1("div", { class: "cte-preview" });
// TODO: types-mediawiki limitation
const updatePreview = mw.util.throttle(() => __awaiter(this, void 0, void 0, function* () {
if (!previewPanel) {
// Skip if still unavailable.
return;
}
yield template.generatePreview().then((data) => {
previewPanel.innerHTML = data;
// Make all anchor links open in a new tab (prevents exit navigation)
previewPanel.querySelectorAll('a')
.forEach((el) => {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
});
// Infuse collapsibles
$(previewPanel).find('.mw-collapsible')
.makeCollapsible();
$(previewPanel).find('.collapsible')
.each((i, e) => {
$(e).makeCollapsible({
collapsed: e.classList.contains('collapsed')
});
});
});
}), 1000);
// Listen for changes
template.addEventListener('save', () => {
updatePreview();
});
updatePreview();
return previewPanel;
}
let InternalCopiedTemplatePage;
/**
* UI representation of a {{copied}} template. This representation is further broken
* down with `CopiedTemplateRowPage`, which represents each row on the template.
*
* Note that "Page" in the class title does not refer to a MediaWiki page, but rather
* a OOUI PageLayout.
*/
function initCopiedTemplatePage() {
InternalCopiedTemplatePage = class CopiedTemplatePage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { copiedTemplate, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (copiedTemplate == null) {
throw new Error('Reference template (CopiedTemplate) is required');
}
const label = mw.message('deputy.ante.copied.label', config.copiedTemplate.name).text();
const finalConfig = {
label: label,
classes: ['cte-page-template']
};
super(copiedTemplate.id, finalConfig);
/**
* All child pages of this CopiedTemplatePage. Garbage collected when rechecked.
*/
this.childPages = new Map();
this.document = config.copiedTemplate.parsoid;
this.copiedTemplate = config.copiedTemplate;
this.parent = config.parent;
this.label = label;
copiedTemplate.addEventListener('rowAdd', () => {
parent.rebuildPages();
});
copiedTemplate.addEventListener('rowDelete', () => {
parent.rebuildPages();
});
copiedTemplate.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.$element.append(this.renderButtons(), this.renderHeader(), renderMergePanel('copied', this.copiedTemplate, this.mergeButton), renderPreviewPanel(this.copiedTemplate), this.renderTemplateOptions());
}
/**
* @inheritDoc
*/
getChildren() {
const rows = this.copiedTemplate.rows;
const rowPages = [];
for (const row of rows) {
if (!this.childPages.has(row)) {
this.childPages.set(row, row.generatePage(this.parent));
}
rowPages.push(this.childPages.get(row));
}
// Delete deleted rows from cache.
this.childPages.forEach((page, row) => {
if (rowPages.indexOf(page) === -1) {
this.childPages.delete(row);
}
});
return rowPages;
}
/**
* @return The rendered header of this PageLayout.
*/
renderHeader() {
return h_1("h3", null, this.label);
}
/**
* Renders the set of buttons that appear at the top of the page.
*
* @return A <div> element.
*/
renderButtons() {
const buttonSet = h_1("div", { style: { float: 'right' } });
this.mergeButton = new OO.ui.ButtonWidget({
icon: 'tableMergeCells',
title: mw.msg('deputy.ante.merge'),
framed: false
});
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.copied.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
if (this.copiedTemplate.rows.length > 0) {
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.copied.remove.confirm', `${this.copiedTemplate.rows.length}`).text()).done((confirmed) => {
if (confirmed) {
this.copiedTemplate.destroy();
}
});
}
else {
this.copiedTemplate.destroy();
}
});
const addButton = new OO.ui.ButtonWidget({
flags: ['progressive'],
icon: 'add',
label: mw.msg('deputy.ante.copied.add')
});
addButton.on('click', () => {
this.copiedTemplate.addRow(new CopiedTemplateRow({
to: new mw.Title(this.document.getPage()).getSubjectPage().getPrefixedText()
}, this.copiedTemplate));
});
buttonSet.appendChild(unwrapWidget(this.mergeButton));
buttonSet.appendChild(unwrapWidget(deleteButton));
buttonSet.appendChild(unwrapWidget(addButton));
return buttonSet;
}
/**
* Renders the panel used to merge multiple {{copied}} templates.
*
* @return A <div> element
*/
renderMergePanel() {
return renderMergePanel('copied', this.copiedTemplate, this.mergeButton);
}
/**
* Renders the global options of this template. This includes parameters that are not
* counted towards an entry and affect the template as a whole.
*
* @return A <div> element.
*/
renderTemplateOptions() {
var _a, _b;
this.inputSet = {
collapse: new OO.ui.CheckboxInputWidget({
selected: yesNo((_a = this.copiedTemplate.collapsed) === null || _a === void 0 ? void 0 : _a.trim(), false)
}),
small: new OO.ui.CheckboxInputWidget({
selected: yesNo((_b = this.copiedTemplate.small) === null || _b === void 0 ? void 0 : _b.trim(), false)
})
};
this.fields = {
collapse: new OO.ui.FieldLayout(this.inputSet.collapse, {
label: mw.msg('deputy.ante.copied.collapse'),
align: 'inline'
}),
small: new OO.ui.FieldLayout(this.inputSet.small, {
label: mw.msg('deputy.ante.copied.small'),
align: 'inline'
})
};
this.inputSet.collapse.on('change', (value) => {
this.copiedTemplate.collapsed = value ? 'yes' : null;
this.copiedTemplate.save();
});
this.inputSet.small.on('change', (value) => {
this.copiedTemplate.small = value ? 'yes' : null;
this.copiedTemplate.save();
});
return h_1("div", { class: "cte-templateOptions" },
h_1("div", null, unwrapWidget(this.fields.collapse)),
h_1("div", null, unwrapWidget(this.fields.small)));
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('puzzle')
.setLevel(0)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new CopiedTemplatePage.
*
* @param config Configuration to be passed to the element.
* @return A CopiedTemplatePage object
*/
function CopiedTemplatePage (config) {
if (!InternalCopiedTemplatePage) {
initCopiedTemplatePage();
}
return new InternalCopiedTemplatePage(config);
}
/**
* Renders wikitext as HTML.
*
* @param wikitext
* @param title
* @param options
*/
function renderWikitext(wikitext, title, options = {}) {
return __awaiter(this, void 0, void 0, function* () {
return MwApi.action.post(Object.assign({
action: 'parse',
title: title,
text: wikitext,
preview: true,
disableeditsection: true,
disablelimitreport: true
}, options)).then((data) => {
return Object.assign(data.parse.text, {
summary: data.parse.parsedsummary
});
});
});
}
/**
* The AttributionNotice abstract class serves as the blueprint for other
* subclasses that are instances of AttributionNotices (e.g {@link CopiedTemplate}).
* It provides the basic functionality for the processing of attribution notices.
*/
class AttributionNotice extends EventTarget {
/**
* Super constructor for AttributionNotice subclasses.
*
* @param node
* The ParsoidTransclusionTemplateNode of this notice.
*/
constructor(node) {
super();
this.node = node;
this.name = this.element.getAttribute('about')
.replace(/^#mwt/, '') + '-' + this.i;
this.id = window.btoa(node.getTarget().wt) + '-' + this.name;
this.parse();
}
/**
* @return The ParsoidDocument handling this notice (specifically its node).
*/
get parsoid() {
return this.node.parsoidDocument;
}
/**
* @return The HTMLElement of the node
*/
get element() {
return this.node.element;
}
/**
* @return This template's `i` variable, used to identify this template in
* the template's `parts` (`data-mw`).
*/
get i() {
return this.node.i;
}
/**
* Provides low-level access to a template's `data-mw` entry. When possible,
* use functions from `.node` instead, as these are much more stable.
*
* @param callback The callback for data-modifying operations.
*/
accessTemplateData(callback) {
const jsonData = JSON.parse(this.element.getAttribute('data-mw'));
let templateData;
let index;
jsonData.parts.forEach((v, k) => {
if (v != null && v.template !== undefined && v.template.i === this.i) {
templateData = v;
index = k;
}
});
if (templateData === undefined) {
throw new TypeError('Invalid `i` given to template.');
}
templateData = callback(templateData);
if (templateData === undefined) {
jsonData.parts.splice(index, 1);
}
else {
jsonData.parts[index] = templateData;
}
this.element.setAttribute('data-mw', JSON.stringify(jsonData));
if (jsonData.parts.length === 0) {
this.parsoid.getDocument().querySelectorAll(`[about="${this.element.getAttribute('about')}"]`).forEach((e) => {
e.parentElement.removeChild(e);
});
}
}
/**
* Gets a wikitext string representation of this template. Used for
* previews.
*
* @return wikitext.
*/
toWikitext() {
let wikitext = '{{';
this.accessTemplateData((data) => {
wikitext += data.template.target.wt;
for (const key in data.template.params) {
if (!Object.hasOwnProperty.call(data.template.params, key)) {
continue;
}
const value = data.template.params[key];
wikitext += `| ${key} = ${value.wt}\n`;
}
return data;
});
return wikitext + '}}';
}
/**
* Converts this notice to parsed HTML.
*
* @return {Promise<string>}
*/
generatePreview() {
return __awaiter(this, void 0, void 0, function* () {
return renderWikitext(this.toWikitext(), this.parsoid.getPage());
});
}
}
/**
* An event that reflects a change in a given {{copied}} template
* row.
*/
class RowChangeEvent extends Event {
/**
* Creates a new RowChangeEvent.
*
* @param type The event type.
* @param row The changed row.
*/
constructor(type, row) {
super(type);
this.row = row;
}
}
/**
* This is a sub-abstract class of {@link AttributionNotice} that represents any
* attribution notice template that can contain multiple entries (or rows).
*/
class RowedAttributionNotice extends AttributionNotice {
/**
* @return This template's rows.
*/
get rows() {
return this._rows;
}
/**
* Checks if this current template has row parameters with a given suffix, or no
* suffix if not supplied.
*
* @param parameters The parameter names to check for
* @param suffix The suffix of the parameter
* @return `true` if parameters exist
* @private
*/
hasRowParameters(parameters, suffix = '') {
return Object.keys(this.node.getParameters()).some((v) => parameters.map((v2) => `${v2}${suffix}`)
.indexOf(v) !== -1);
}
/**
* Extracts parameters from `this.node` and returns a row.
*
* @param parameters The parameter names to check for
* @param suffix The suffix of the parameter
* @return A row, or null if no parameters were found.
* @private
*/
extractRowParameters(parameters, suffix = '') {
const row = {};
parameters.forEach((key) => {
if (this.node.hasParameter(key + suffix) !== undefined) {
row[key] = this.node.getParameter(key + suffix);
}
else if (suffix === '' && this.node.hasParameter(`${key}1`)) {
// Non-numbered parameter not found but a numbered parameter with
// an index of 1 was found. Fall back to that value.
row[key] = this.node.getParameter(`${key}1`).trim();
}
else if (suffix === 1 && this.node.hasParameter(`${key}`)) {
// This is i = 1, so fall back to a non-numbered parameter (if exists)
const unnumberedParamValue = this.node.getParameter(`${key}`).trim();
if (unnumberedParamValue !== undefined) {
row[key] = unnumberedParamValue;
}
}
});
return row;
}
/**
* Adds a row to this template.
*
* @param row The row to add.
*/
addRow(row) {
this._rows.push(row);
this.save();
this.dispatchEvent(new RowChangeEvent('rowAdd', row));
}
/**
* Deletes a row to this template.
*
* @param row The row to delete.
*/
deleteRow(row) {
const i = this._rows.findIndex((v) => v === row);
if (i !== -1) {
this._rows.splice(i, 1);
this.save();
this.dispatchEvent(new RowChangeEvent('rowDelete', row));
}
if (this._rows.length === 0) {
this.destroy();
}
}
/**
* Copies in the rows of another {@link SplitArticleTemplate}, and
* optionally deletes that template or clears its contents.
*
* @param template The template to copy from.
* @param options Options for this merge.
* @param options.delete
* Whether the reference template will be deleted after merging.
* @param options.clear
* Whether the reference template's rows will be cleared after merging.
*/
merge(template, options = {}) {
if (template.rows === undefined || template === this) {
// Deleted or self
return;
}
for (const row of template.rows) {
if (options.clear) {
row.parent = this;
}
else {
this.addRow(row.clone(this));
}
}
if (options.delete) {
template.destroy();
}
}
}
/**
* Represents a single {{copied}} template in the Parsoid document.
*/
class CopiedTemplate extends RowedAttributionNotice {
/**
* @return This template's rows.
*/
get rows() {
return this._rows;
}
/**
* @inheritDoc
*/
parse() {
if (this.node.getParameter('collapse')) {
this.collapsed = this.node.getParameter('collapse');
}
if (this.node.getParameter('small')) {
this.small = this.node.getParameter('small');
}
// Extract {{copied}} rows.
const rows = [];
// Numberless
if (this.hasRowParameters(copiedTemplateRowParameters)) {
// If `from`, `to`, ..., or `merge` is found.
rows.push(new CopiedTemplateRow(this.extractRowParameters(copiedTemplateRowParameters), this));
}
// Numbered
let i = 1, continueExtracting = true;
do {
if (this.hasRowParameters(copiedTemplateRowParameters, i)) {
rows.push(new CopiedTemplateRow(this.extractRowParameters(copiedTemplateRowParameters, i), this));
}
else if (!(i === 1 && rows.length > 0)) {
// Row doesn't exist. Stop parsing from here.
continueExtracting = false;
}
i++;
} while (continueExtracting);
/**
* All the rows of this template.
*
* @type {CopiedTemplateRow[]}
*/
this._rows = rows;
}
/**
* @inheritDoc
*/
save() {
if (this.collapsed !== undefined) {
this.node.setParameter('collapse', yesNo(this.collapsed) ? 'yes' : null);
}
if (this.small !== undefined) {
this.node.setParameter('small', yesNo(this.small) ? 'yes' : null);
}
const existingParameters = this.node.getParameters();
for (const param in existingParameters) {
if (copiedTemplateRowParameters.some((v) => param.startsWith(v))) {
// This is a row parameter. Remove it in preparation for rebuild (further below).
this.node.removeParameter(param);
}
}
if (this._rows.length === 1) {
// If there is only one row, don't bother with numbered rows.
for (const param of copiedTemplateRowParameters) {
if (this._rows[0][param] !== undefined) {
this.node.setParameter(param, this._rows[0][param]);
}
}
}
else {
// If there are multiple rows, add number suffixes (except for i = 0).
for (let i = 0; i < this._rows.length; i++) {
for (const param of copiedTemplateRowParameters) {
if (this._rows[i][param] !== undefined) {
this.node.setParameter(param + (i === 0 ? '' : i + 1), this._rows[i][param]);
}
}
}
}
this.dispatchEvent(new Event('save'));
}
/**
* @inheritDoc
*/
destroy() {
this.node.destroy();
// Self-destruct
Object.keys(this).forEach((k) => delete this[k]);
this.dispatchEvent(new Event('destroy'));
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return CopiedTemplatePage({
copiedTemplate: this,
parent: dialog
});
}
/**
* Copies in the rows of another {@link CopiedTemplate}, and
* optionally deletes that template or clears its contents.
*
* @param template The template to copy from.
* @param options Options for this merge.
* @param options.delete
* Whether the reference template will be deleted after merging.
* @param options.clear
* Whether the reference template's rows will be cleared after merging.
*/
merge(template, options = {}) {
if (template.rows === undefined || template === this) {
// Deleted or self
return;
}
for (const row of template.rows) {
if (options.clear) {
row.parent = this;
}
else {
this.addRow(row.clone(this));
}
}
if (options.delete) {
template.destroy();
}
}
}
let InternalSplitArticleTemplateRowPage;
/**
* Initializes the process element.
*/
function initSplitArticleTemplateRowPage() {
InternalSplitArticleTemplateRowPage = class SplitArticleTemplateRowPage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { splitArticleTemplateRow, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (splitArticleTemplateRow == null) {
throw new Error('Reference row (SplitArticleTemplateRow) is required');
}
const finalConfig = {
classes: ['cte-page-row']
};
super(splitArticleTemplateRow.id, finalConfig);
this.parent = parent;
this.splitArticleTemplateRow = splitArticleTemplateRow;
this.refreshLabel();
this.splitArticleTemplateRow.parent.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.splitArticleTemplateRow.parent.addEventListener('rowDelete', () => {
parent.rebuildPages();
});
this.$element.append(this.render().$element);
}
/**
* Refreshes the page's label
*/
refreshLabel() {
this.label = mw.message('deputy.ante.splitArticle.entry.short', this.splitArticleTemplateRow.to || '???', this.splitArticleTemplateRow.date || '???').text();
if (this.outlineItem) {
this.outlineItem.setLabel(this.label);
}
}
/**
* Renders this page. Returns a FieldsetLayout OOUI widget.
*
* @return An OOUI FieldsetLayout
*/
render() {
this.layout = new OO.ui.FieldsetLayout({
icon: 'parameter',
label: mw.msg('deputy.ante.splitArticle.entry.label'),
classes: ['cte-fieldset']
});
this.layout.$element.append(this.renderButtons());
this.layout.addItems(this.renderFields());
return this.layout;
}
/**
* Renders a set of buttons used to modify a specific {{copied}} template row.
*
* @return An array of OOUI FieldLayouts
*/
renderButtons() {
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.splitArticle.entry.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
this.splitArticleTemplateRow.parent.deleteRow(this.splitArticleTemplateRow);
});
return h_1("div", { style: {
float: 'right',
position: 'absolute',
top: '0.5em',
right: '0.5em'
} }, unwrapWidget(deleteButton));
}
/**
* Renders a set of OOUI InputWidgets and FieldLayouts, eventually returning an
* array of each FieldLayout to append to the FieldsetLayout.
*
* @return An array of OOUI FieldLayouts
*/
renderFields() {
const rowDate = this.splitArticleTemplateRow.date;
const parsedDate = (rowDate == null || rowDate.trim().length === 0) ?
undefined : (!isNaN(new Date(rowDate.trim() + ' UTC').getTime()) ?
(new Date(rowDate.trim() + ' UTC')) : (!isNaN(new Date(rowDate.trim()).getTime()) ?
new Date(rowDate.trim()) : null));
const inputs = {
to: SmartTitleInputWidget({
$overlay: this.parent.$overlay,
required: true,
value: this.splitArticleTemplateRow.to || '',
placeholder: mw.msg('deputy.ante.splitArticle.to.placeholder')
}),
// eslint-disable-next-line camelcase
from_oldid: new OO.ui.TextInputWidget({
value: this.splitArticleTemplateRow.from_oldid || '',
placeholder: mw.msg('deputy.ante.splitArticle.from_oldid.placeholder')
}),
diff: new OO.ui.TextInputWidget({
value: this.splitArticleTemplateRow.from_oldid || '',
placeholder: mw.msg('deputy.ante.splitArticle.diff.placeholder'),
validate: (value) => {
if (
// Blank
value.trim().length === 0 ||
// Diff number
!isNaN(+value)) {
return true;
}
try {
return typeof new URL(value).href === 'string';
}
catch (e) {
return false;
}
}
}),
date: new mw.widgets.DateInputWidget({
$overlay: this.parent.$overlay,
required: true,
icon: 'calendar',
value: parsedDate ? `${parsedDate.getUTCFullYear()}-${parsedDate.getUTCMonth() + 1}-${parsedDate.getUTCDate()}` : undefined,
placeholder: mw.msg('deputy.ante.copied.date.placeholder'),
calendar: {
verticalPosition: 'above'
}
})
};
const dateAuto = RevisionDateGetButton({
label: mw.msg('deputy.ante.dateAuto', 'diff'),
revisionInputWidget: inputs.diff,
dateInputWidget: inputs.date
});
const fieldLayouts = {
to: new OO.ui.FieldLayout(inputs.to, {
$overlay: this.parent.$overlay,
align: 'top',
label: mw.msg('deputy.ante.splitArticle.to.label'),
help: mw.msg('deputy.ante.splitArticle.to.help')
}),
// eslint-disable-next-line camelcase
from_oldid: new OO.ui.FieldLayout(inputs.from_oldid, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.splitArticle.from_oldid.label'),
help: mw.msg('deputy.ante.splitArticle.from_oldid.help')
}),
diff: new OO.ui.FieldLayout(inputs.diff, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.splitArticle.diff.label'),
help: mw.msg('deputy.ante.splitArticle.diff.help')
}),
date: new OO.ui.ActionFieldLayout(inputs.date, dateAuto, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.splitArticle.date.label'),
help: mw.msg('deputy.ante.splitArticle.date.help')
})
};
for (const _field in inputs) {
const field = _field;
const input = inputs[field];
// Attach the change listener
input.on('change', (value) => {
if (input instanceof mw.widgets.DateInputWidget) {
this.splitArticleTemplateRow[field] = value ?
window.moment(value, 'YYYY-MM-DD')
.locale(mw.config.get('wgContentLanguage'))
.format('D MMMM Y') : undefined;
if (value.length > 0) {
fieldLayouts[field].setWarnings([]);
}
}
else {
this.splitArticleTemplateRow[field] = value;
}
this.splitArticleTemplateRow.parent.save();
});
if (input instanceof OO.ui.TextInputWidget) {
// Rechecks the validity of the field.
input.setValidityFlag();
}
}
inputs.to.on('change', () => {
this.refreshLabel();
});
inputs.date.on('change', () => {
this.refreshLabel();
});
return getObjectValues(fieldLayouts);
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('parameter')
.setLevel(1)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new SplitArticleTemplateRowPage.
*
* @param config Configuration to be passed to the element.
* @return A SplitArticleTemplateRowPage object
*/
function SplitArticleTemplateRowPage (config) {
if (!InternalSplitArticleTemplateRowPage) {
initSplitArticleTemplateRowPage();
}
return new InternalSplitArticleTemplateRowPage(config);
}
// noinspection JSDeprecatedSymbols
const splitArticleTemplateRowParameters = [
'to', 'from_oldid', 'date', 'diff'
];
/**
* Represents a row/entry in a {{split article}} template.
*/
class SplitArticleTemplateRow extends AttributionNoticeRow {
/**
* Creates a new RawCopiedTemplateRow
*
* @param rowObjects
* @param parent
*/
constructor(rowObjects, parent) {
super(parent);
this.to = rowObjects.to;
// eslint-disable-next-line camelcase
this.from_oldid = rowObjects.from_oldid;
this.date = rowObjects.date;
this.diff = rowObjects.diff;
this._parent = parent;
}
/**
* @inheritDoc
*/
clone(parent) {
return super.clone(parent);
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return SplitArticleTemplateRowPage({
splitArticleTemplateRow: this,
parent: dialog
});
}
}
let InternalSplitArticleTemplatePage;
/**
* Initializes the process element.
*/
function initSplitArticleTemplatePage() {
InternalSplitArticleTemplatePage = class SplitArticleTemplatePage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { splitArticleTemplate, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (splitArticleTemplate == null) {
throw new Error('Reference template (SplitArticleTemplate) is required');
}
const label = mw.message('deputy.ante.splitArticle.label', config.splitArticleTemplate.name).text();
const finalConfig = {
label: label,
classes: ['cte-page-template']
};
super(splitArticleTemplate.id, finalConfig);
/**
* All child pages of this splitArticleTemplatePage. Garbage collected when rechecked.
*/
this.childPages = new Map();
this.document = config.splitArticleTemplate.parsoid;
this.splitArticleTemplate = config.splitArticleTemplate;
this.parent = config.parent;
this.label = label;
splitArticleTemplate.addEventListener('rowAdd', () => {
parent.rebuildPages();
});
splitArticleTemplate.addEventListener('rowDelete', () => {
parent.rebuildPages();
});
splitArticleTemplate.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.$element.append(this.renderButtons(), this.renderHeader(), renderMergePanel('splitArticle', this.splitArticleTemplate, this.mergeButton), renderPreviewPanel(this.splitArticleTemplate), this.renderTemplateOptions());
}
/**
* @inheritDoc
*/
getChildren() {
const rows = this.splitArticleTemplate.rows;
const rowPages = [];
for (const row of rows) {
if (!this.childPages.has(row)) {
this.childPages.set(row, row.generatePage(this.parent));
}
rowPages.push(this.childPages.get(row));
}
// Delete deleted rows from cache.
this.childPages.forEach((page, row) => {
if (rowPages.indexOf(page) === -1) {
this.childPages.delete(row);
}
});
return rowPages;
}
/**
* Renders the set of buttons that appear at the top of the page.
*
* @return A <div> element.
*/
renderButtons() {
const buttonSet = h_1("div", { style: { float: 'right' } });
this.mergeButton = new OO.ui.ButtonWidget({
icon: 'tableMergeCells',
title: mw.msg('deputy.ante.merge'),
framed: false
});
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.splitArticle.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
if (this.splitArticleTemplate.rows.length > 0) {
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.splitArticle.remove.confirm', `${this.splitArticleTemplate.rows.length}`).text()).done((confirmed) => {
if (confirmed) {
this.splitArticleTemplate.destroy();
}
});
}
else {
this.splitArticleTemplate.destroy();
}
});
const addButton = new OO.ui.ButtonWidget({
flags: ['progressive'],
icon: 'add',
label: mw.msg('deputy.ante.splitArticle.add')
});
addButton.on('click', () => {
this.splitArticleTemplate.addRow(new SplitArticleTemplateRow({}, this.splitArticleTemplate));
});
this.splitArticleTemplate.addEventListener('rowAdd', () => {
// TODO: Remove after template improvements.
addButton.setDisabled(this.splitArticleTemplate.rows.length >= 10);
});
buttonSet.appendChild(unwrapWidget(this.mergeButton));
buttonSet.appendChild(unwrapWidget(deleteButton));
buttonSet.appendChild(unwrapWidget(addButton));
return buttonSet;
}
/**
* @return The rendered header of this PageLayout.
*/
renderHeader() {
return h_1("h3", null, this.label);
}
/**
* Renders the global options of this template. This includes parameters that are not
* counted towards an entry and affect the template as a whole.
*
* @return A <div> element.
*/
renderTemplateOptions() {
const page = new mw.Title(this.splitArticleTemplate.parsoid.getPage()).getSubjectPage().getPrefixedText();
const collapse = new OO.ui.CheckboxInputWidget({
selected: this.splitArticleTemplate.collapse ?
yesNo(this.splitArticleTemplate.collapse) : false
});
const from = SmartTitleInputWidget({
$overlay: this.parent.$overlay,
value: this.splitArticleTemplate.from || '',
placeholder: page
});
collapse.on('change', (value) => {
this.splitArticleTemplate.collapse = value ? 'yes' : null;
this.splitArticleTemplate.save();
});
from.on('change', (value) => {
this.splitArticleTemplate.from = value.length > 0 ? value : page;
this.splitArticleTemplate.save();
});
return h_1("div", { class: "cte-templateOptions" },
h_1("div", null, unwrapWidget(new OO.ui.FieldLayout(from, {
$overlay: this.parent.$overlay,
align: 'top',
label: mw.msg('deputy.ante.splitArticle.from'),
help: mw.msg('deputy.ante.splitArticle.from.help')
}))),
h_1("div", { style: {
flex: '0',
alignSelf: 'center',
marginLeft: '8px'
} }, unwrapWidget(new OO.ui.FieldLayout(collapse, {
$overlay: this.parent.$overlay,
align: 'top',
label: mw.msg('deputy.ante.splitArticle.collapse')
}))));
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('puzzle')
.setLevel(0)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new SplitArticleTemplatePage.
*
* @param config Configuration to be passed to the element.
* @return A SplitArticleTemplatePage object
*/
function SplitArticleTemplatePage (config) {
if (!InternalSplitArticleTemplatePage) {
initSplitArticleTemplatePage();
}
return new InternalSplitArticleTemplatePage(config);
}
/**
* Represents a single {{split article}} template.
*/
class SplitArticleTemplate extends RowedAttributionNotice {
/**
* @inheritDoc
*/
parse() {
if (this.node.hasParameter('from')) {
this.from = this.node.getParameter('from');
}
if (this.node.hasParameter('collapse')) {
this.collapse = this.node.getParameter('collapse');
}
// Extract {{copied}} rows.
const rows = [];
// Numberless
if (this.hasRowParameters(splitArticleTemplateRowParameters)) {
// If `from`, `to`, ..., or `merge` is found.
rows.push(new SplitArticleTemplateRow(this.extractRowParameters(splitArticleTemplateRowParameters), this));
}
// Numbered
let i = 1, continueExtracting = true;
do {
if (this.hasRowParameters(splitArticleTemplateRowParameters, i)) {
rows.push(new SplitArticleTemplateRow(this.extractRowParameters(splitArticleTemplateRowParameters, i), this));
}
else if (!(i === 1 && rows.length > 0)) {
// Row doesn't exist. Stop parsing from here.
continueExtracting = false;
}
i++;
// Hard limit to `i` added due to the template's construction.
// TODO: Modify template to allow more than 10.
} while (continueExtracting && i <= 10);
this._rows = rows;
}
/**
* @inheritDoc
*/
save() {
if (this.collapse !== undefined) {
this.node.setParameter('collapse', yesNo(this.collapse) ? 'yes' : null);
}
this.node.setParameter('from', this.from);
const existingParameters = this.node.getParameters();
for (const param in existingParameters) {
if (splitArticleTemplateRowParameters.some((v) => param.startsWith(v))) {
// This is a row parameter. Remove it in preparation for rebuild (further below).
this.node.removeParameter(param);
}
}
this._rows.forEach((row, i) => {
this.node.setParameter(`to${i > 0 ? i + 1 : ''}`, row.to);
this.node.setParameter(`from_oldid${i > 0 ? i + 1 : ''}`, row.from_oldid);
this.node.setParameter(`date${i > 0 ? i + 1 : ''}`, row.date);
this.node.setParameter(`diff${i > 0 ? i + 1 : ''}`, row.diff);
});
this.dispatchEvent(new Event('save'));
}
/**
*
* @inheritDoc
*/
destroy() {
this.node.destroy();
// Self-destruct
Object.keys(this).forEach((k) => delete this[k]);
this.dispatchEvent(new Event('destroy'));
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return SplitArticleTemplatePage({
splitArticleTemplate: this,
parent: dialog
});
}
}
let InternalMergedFromTemplatePage;
/**
* Initializes the process element.
*/
function initMergedFromTemplatePage() {
InternalMergedFromTemplatePage = class MergedFromTemplatePage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { mergedFromTemplate, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (mergedFromTemplate == null) {
throw new Error('Reference template (MergedFromTemplate) is required');
}
const finalConfig = {
classes: ['cte-page-template']
};
super(mergedFromTemplate.id, finalConfig);
this.document = mergedFromTemplate.parsoid;
this.mergedFromTemplate = mergedFromTemplate;
this.parent = config.parent;
this.refreshLabel();
mergedFromTemplate.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.$element.append(this.renderButtons(), this.renderHeader(), renderPreviewPanel(this.mergedFromTemplate), this.renderTemplateOptions());
}
/**
* Refreshes the page's label
*/
refreshLabel() {
this.label = mw.message('deputy.ante.mergedFrom.label', this.mergedFromTemplate.article || '???').text();
if (this.outlineItem) {
this.outlineItem.setLabel(this.label);
}
}
/**
* Renders the set of buttons that appear at the top of the page.
*
* @return A <div> element.
*/
renderButtons() {
const buttonSet = h_1("div", { style: { float: 'right' } });
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.mergedFrom.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
this.mergedFromTemplate.destroy();
});
buttonSet.appendChild(unwrapWidget(deleteButton));
return buttonSet;
}
/**
* @return The rendered header of this PageLayout.
*/
renderHeader() {
return h_1("h3", null, this.label);
}
/**
* @return The options for this template
*/
renderTemplateOptions() {
const layout = new OO.ui.FieldsetLayout({
icon: 'parameter',
label: mw.msg('deputy.ante.templateOptions'),
classes: ['cte-fieldset']
});
const rowDate = this.mergedFromTemplate.date;
const parsedDate = (rowDate == null || rowDate.trim().length === 0) ?
undefined : (!isNaN(new Date(rowDate.trim() + ' UTC').getTime()) ?
(new Date(rowDate.trim() + ' UTC')) : (!isNaN(new Date(rowDate.trim()).getTime()) ?
new Date(rowDate.trim()) : null));
const inputs = {
article: SmartTitleInputWidget({
$overlay: this.parent.$overlay,
required: true,
value: this.mergedFromTemplate.article || '',
placeholder: mw.msg('deputy.ante.mergedFrom.article.placeholder')
}),
date: new mw.widgets.DateInputWidget({
$overlay: this.parent.$overlay,
required: true,
icon: 'calendar',
value: parsedDate ? `${parsedDate.getUTCFullYear()}-${parsedDate.getUTCMonth() + 1}-${parsedDate.getUTCDate()}` : undefined,
placeholder: mw.msg('deputy.ante.copied.date.placeholder')
}),
target: SmartTitleInputWidget({
$overlay: this.parent.$overlay,
value: this.mergedFromTemplate.target || '',
placeholder: mw.msg('deputy.ante.mergedFrom.target.placeholder')
}),
afd: new mw.widgets.TitleInputWidget({
$overlay: this.parent.$overlay,
value: this.mergedFromTemplate.afd || '',
placeholder: mw.msg('deputy.ante.mergedFrom.afd.placeholder'),
validate: (title) => {
// TODO: ANTE l10n
return title.trim().length === 0 || title.startsWith(new mw.Title('Articles for deletion/', nsId('wikipedia'))
.toText());
}
}),
talk: new OO.ui.CheckboxInputWidget({
selected: yesNo(this.mergedFromTemplate.target)
})
};
const fieldLayouts = {
article: new OO.ui.FieldLayout(inputs.article, {
$overlay: this.parent.$overlay,
align: 'top',
label: mw.msg('deputy.ante.mergedFrom.article.label'),
help: mw.msg('deputy.ante.mergedFrom.article.help')
}),
date: new OO.ui.FieldLayout(inputs.date, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.mergedFrom.date.label'),
help: mw.msg('deputy.ante.mergedFrom.date.help')
}),
target: new OO.ui.FieldLayout(inputs.target, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.mergedFrom.target.label'),
help: mw.msg('deputy.ante.mergedFrom.target.help')
}),
afd: new OO.ui.FieldLayout(inputs.afd, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.mergedFrom.afd.label'),
help: mw.msg('deputy.ante.mergedFrom.afd.help')
}),
talk: new OO.ui.FieldLayout(inputs.talk, {
$overlay: this.parent.$overlay,
align: 'inline',
label: mw.msg('deputy.ante.mergedFrom.talk.label'),
help: mw.msg('deputy.ante.mergedFrom.talk.help')
})
};
for (const _field in inputs) {
const field = _field;
const input = inputs[field];
// Attach the change listener
input.on('change', (value) => {
if (input instanceof OO.ui.CheckboxInputWidget) {
this.mergedFromTemplate[field] = value ? 'yes' : 'no';
}
else if (input instanceof mw.widgets.DateInputWidget) {
this.mergedFromTemplate[field] = value ?
window.moment(value, 'YYYY-MM-DD')
.locale(mw.config.get('wgContentLanguage'))
.format('D MMMM Y') : undefined;
if (value.length > 0) {
fieldLayouts[field].setWarnings([]);
}
}
else {
this.mergedFromTemplate[field] = value;
}
this.mergedFromTemplate.save();
});
if (input instanceof OO.ui.TextInputWidget) {
// Rechecks the validity of the field.
input.setValidityFlag();
}
}
inputs.article.on('change', () => {
this.refreshLabel();
});
layout.addItems(getObjectValues(fieldLayouts));
return unwrapWidget(layout);
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('puzzle')
.setLevel(0)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new MergedFromTemplatePage.
*
* @param config Configuration to be passed to the element.
* @return A MergedFromTemplatePage object
*/
function MergedFromTemplatePage (config) {
if (!InternalMergedFromTemplatePage) {
initMergedFromTemplatePage();
}
return new InternalMergedFromTemplatePage(config);
}
/**
* Represents a single {{merged-from}} template in the Parsoid document.
*/
class MergedFromTemplate extends AttributionNotice {
/**
* @inheritDoc
*/
parse() {
if (this.node.hasParameter('1')) {
this.article = this.node.getParameter('1');
}
if (this.node.hasParameter('2')) {
this.date = this.node.getParameter('2');
}
if (this.node.hasParameter('talk')) {
this.talk = this.node.getParameter('talk');
}
if (this.node.hasParameter('target')) {
this.target = this.node.getParameter('target');
}
if (this.node.hasParameter('afd')) {
this.afd = this.node.getParameter('afd');
}
}
/**
* @inheritDoc
*/
save() {
var _a, _b;
this.node.setParameter('1', this.article);
this.node.setParameter('2', this.date);
if (this.talk !== undefined) {
this.node.setParameter('talk', yesNo(this.talk) ? null : 'no');
}
this.node.setParameter('target', ((_a = this.target) !== null && _a !== void 0 ? _a : '').length > 0 ? this.target : null);
this.node.setParameter('afd', ((_b = this.afd) !== null && _b !== void 0 ? _b : '').length > 0 ? this.afd : null);
this.dispatchEvent(new Event('save'));
}
/**
* @inheritDoc
*/
destroy() {
this.node.destroy();
// Self-destruct
Object.keys(this).forEach((k) => delete this[k]);
this.dispatchEvent(new Event('destroy'));
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return MergedFromTemplatePage({
mergedFromTemplate: this,
parent: dialog
});
}
}
let InternalMergedToTemplatePage;
/**
* Initializes the process element.
*/
function initMergedToTemplatePage() {
InternalMergedToTemplatePage = class MergedToTemplatePage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { mergedToTemplate, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (mergedToTemplate == null) {
throw new Error('Reference template (MergedToTemplate) is required');
}
const finalConfig = {
classes: ['cte-page-template']
};
super(mergedToTemplate.id, finalConfig);
this.document = mergedToTemplate.parsoid;
this.mergedToTemplate = mergedToTemplate;
this.parent = config.parent;
this.refreshLabel();
mergedToTemplate.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.$element.append(this.renderButtons(), this.renderHeader(), renderPreviewPanel(this.mergedToTemplate), this.renderTemplateOptions());
}
/**
* Refreshes the page's label
*/
refreshLabel() {
this.label = mw.message('deputy.ante.mergedTo.label', this.mergedToTemplate.to || '???').text();
if (this.outlineItem) {
this.outlineItem.setLabel(this.label);
}
}
/**
* Renders the set of buttons that appear at the top of the page.
*
* @return A <div> element.
*/
renderButtons() {
const buttonSet = h_1("div", { style: { float: 'right' } });
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.mergedTo.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
this.mergedToTemplate.destroy();
});
buttonSet.appendChild(unwrapWidget(deleteButton));
return buttonSet;
}
/**
* @return The rendered header of this PageLayout.
*/
renderHeader() {
return h_1("h3", null, this.label);
}
/**
* @return The options for this template
*/
renderTemplateOptions() {
const layout = new OO.ui.FieldsetLayout({
icon: 'parameter',
label: mw.msg('deputy.ante.templateOptions'),
classes: ['cte-fieldset']
});
const rowDate = this.mergedToTemplate.date;
const parsedDate = (rowDate == null || rowDate.trim().length === 0) ?
undefined : (!isNaN(new Date(rowDate.trim() + ' UTC').getTime()) ?
(new Date(rowDate.trim() + ' UTC')) : (!isNaN(new Date(rowDate.trim()).getTime()) ?
new Date(rowDate.trim()) : null));
const inputs = {
to: SmartTitleInputWidget({
$overlay: this.parent.$overlay,
required: true,
value: this.mergedToTemplate.to || '',
placeholder: mw.msg('deputy.ante.mergedTo.to.placeholder')
}),
date: new mw.widgets.DateInputWidget({
$overlay: this.parent.$overlay,
required: true,
icon: 'calendar',
value: parsedDate ? `${parsedDate.getUTCFullYear()}-${parsedDate.getUTCMonth() + 1}-${parsedDate.getUTCDate()}` : undefined,
placeholder: mw.msg('deputy.ante.copied.date.placeholder')
}),
small: new OO.ui.CheckboxInputWidget({
selected: yesNo(this.mergedToTemplate.small, false)
})
};
const fieldLayouts = {
to: new OO.ui.FieldLayout(inputs.to, {
$overlay: this.parent.$overlay,
align: 'top',
label: mw.msg('deputy.ante.mergedTo.to.label'),
help: mw.msg('deputy.ante.mergedTo.to.help')
}),
date: new OO.ui.FieldLayout(inputs.date, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.mergedTo.date.label'),
help: mw.msg('deputy.ante.mergedTo.date.help')
}),
small: new OO.ui.FieldLayout(inputs.small, {
$overlay: this.parent.$overlay,
align: 'inline',
label: mw.msg('deputy.ante.mergedTo.small.label'),
help: mw.msg('deputy.ante.mergedTo.small.help')
})
};
for (const _field in inputs) {
const field = _field;
const input = inputs[field];
// Attach the change listener
input.on('change', (value) => {
if (input instanceof OO.ui.CheckboxInputWidget) {
this.mergedToTemplate[field] = value ? 'yes' : 'no';
}
else if (input instanceof mw.widgets.DateInputWidget) {
this.mergedToTemplate[field] = value ?
window.moment(value, 'YYYY-MM-DD')
.locale(mw.config.get('wgContentLanguage'))
.format('D MMMM Y') : undefined;
if (value.length > 0) {
fieldLayouts[field].setWarnings([]);
}
}
else {
this.mergedToTemplate[field] = value;
}
this.mergedToTemplate.save();
});
if (input instanceof OO.ui.TextInputWidget) {
// Rechecks the validity of the field.
input.setValidityFlag();
}
}
inputs.to.on('change', () => {
this.refreshLabel();
});
layout.addItems(getObjectValues(fieldLayouts));
return unwrapWidget(layout);
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('puzzle')
.setLevel(0)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new MergedToTemplatePage.
*
* @param config Configuration to be passed to the element.
* @return A MergedToTemplatePage object
*/
function MergedToTemplatePage (config) {
if (!InternalMergedToTemplatePage) {
initMergedToTemplatePage();
}
return new InternalMergedToTemplatePage(config);
}
/**
* Represents a single {{merged-to}} template in the Parsoid document.
*/
class MergedToTemplate extends AttributionNotice {
/**
* inheritDoc
*/
parse() {
if (this.node.hasParameter('to')) {
this.to = this.node.getParameter('to');
}
else if (this.node.hasParameter('1')) {
this.to = this.node.getParameter('1');
}
if (this.node.hasParameter('date')) {
this.date = this.node.getParameter('date');
}
else if (this.node.hasParameter('2')) {
this.date = this.node.getParameter('2');
}
if (this.node.hasParameter('small')) {
this.small = this.node.getParameter('small');
}
}
/**
* @inheritDoc
*/
save() {
// Reset named parameters
this.node.setParameter('to', null);
this.node.setParameter('date', null);
this.node.setParameter('1', this.to);
this.node.setParameter('2', this.date);
if (this.small !== undefined) {
this.node.setParameter('small', yesNo(this.small) ? 'yes' : null);
}
this.dispatchEvent(new Event('save'));
}
/**
* @inheritDoc
*/
destroy() {
this.node.destroy();
// Self-destruct
Object.keys(this).forEach((k) => delete this[k]);
this.dispatchEvent(new Event('destroy'));
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return MergedToTemplatePage({
mergedToTemplate: this,
parent: dialog
});
}
}
/**
* Replacement for String.prototype.matchALl (ES2020 only)
*
* @param _regex The regular expression to exec with
* @param string The string to exec against
* @return The matches found
*/
function matchAll(_regex, string) {
const regex = cloneRegex$1(_regex);
const res = [];
let current = regex.exec(string);
while (current != null) {
res.push(current);
current = regex.exec(string);
}
return res;
}
let InternalBackwardsCopyTemplateRowPage;
/**
* The UI representation of a {{copied}} template row. This refers to a set of `diff`, `to`,
* or `from` parameters on each {{copied}} template.
*
* Note that "Page" in the class title does not refer to a MediaWiki page, but rather
* a OOUI PageLayout.
*/
function initBackwardsCopyTemplateRowPage() {
InternalBackwardsCopyTemplateRowPage = class BackwardsCopyTemplateRowPage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { backwardsCopyTemplateRow, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (BackwardsCopyTemplateEditorDialog) is required');
}
else if (backwardsCopyTemplateRow == null) {
throw new Error('Reference row (BackwardsCopyTemplateRow) is required');
}
const finalConfig = {
classes: ['cte-page-row']
};
super(backwardsCopyTemplateRow.id, finalConfig);
this.parent = parent;
this.backwardsCopyTemplateRow = backwardsCopyTemplateRow;
this.refreshLabel();
this.backwardsCopyTemplateRow.parent.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.backwardsCopyTemplateRow.parent.addEventListener('rowDelete', () => {
parent.rebuildPages();
});
this.backwardsCopyTemplateRow.parent.addEventListener('save', () => {
this.refreshLabel();
});
this.$element.append(this.render().$element);
}
/**
* Refreshes the page's label
*/
refreshLabel() {
this.label = mw.message('deputy.ante.backwardsCopy.entry.short', this.backwardsCopyTemplateRow.title || '???').text();
if (this.outlineItem) {
this.outlineItem.setLabel(this.label);
}
}
/**
* Renders this page. Returns a FieldsetLayout OOUI widget.
*
* @return An OOUI FieldsetLayout
*/
render() {
this.layout = new OO.ui.FieldsetLayout({
icon: 'parameter',
label: mw.msg('deputy.ante.copied.entry.label'),
classes: ['cte-fieldset']
});
this.layout.$element.append(this.renderButtons());
this.layout.addItems(this.renderFields());
return this.layout;
}
/**
* Renders a set of buttons used to modify a specific {{copied}} template row.
*
* @return An array of OOUI FieldLayouts
*/
renderButtons() {
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.backwardsCopy.entry.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
this.backwardsCopyTemplateRow.parent.deleteRow(this.backwardsCopyTemplateRow);
});
return h_1("div", { style: {
float: 'right',
position: 'absolute',
top: '0.5em',
right: '0.5em'
} }, unwrapWidget(deleteButton));
}
/**
* Renders a set of OOUI InputWidgets and FieldLayouts, eventually returning an
* array of each FieldLayout to append to the FieldsetLayout.
*
* @return An array of OOUI FieldLayouts
*/
renderFields() {
var _a, _b, _c, _d;
// Use order: `date`, `monthday` + `year`, `year`
const rowDate = (_a = this.backwardsCopyTemplateRow.date) !== null && _a !== void 0 ? _a : (this.backwardsCopyTemplateRow.monthday ?
`${this.backwardsCopyTemplateRow.monthday} ${this.backwardsCopyTemplateRow.year}` :
this.backwardsCopyTemplateRow.year);
// TODO: ANTE l10n
const authorRegex = /(.+?, (?:[A-Z]\.\s?)*)(?:(?:&|[&;]|[,;] (?:&|[&;])?)\s*|$)/g;
const authors = matchAll(authorRegex, (_b = this.backwardsCopyTemplateRow.authorlist) !== null && _b !== void 0 ? _b : this.backwardsCopyTemplateRow.author).map((v) => v[1]);
const inputs = {
title: new OO.ui.TextInputWidget({
required: true,
placeholder: mw.msg('deputy.ante.backwardsCopy.entry.title.placeholder'),
value: (_c = this.backwardsCopyTemplateRow.title) !== null && _c !== void 0 ? _c : this.backwardsCopyTemplateRow.articlename
}),
date: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.backwardsCopy.entry.date.placeholder'),
value: rowDate
}),
author: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.backwardsCopy.entry.author.placeholder'),
value: (_d = authors[0]) !== null && _d !== void 0 ? _d : this.backwardsCopyTemplateRow.author
}),
url: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.backwardsCopy.entry.url.placeholder'),
value: this.backwardsCopyTemplateRow.url,
validate: (value) => {
if (value.trim().length === 0) {
return true;
}
try {
return typeof new URL(value).href === 'string';
}
catch (e) {
return false;
}
}
}),
org: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.backwardsCopy.entry.org.placeholder'),
value: this.backwardsCopyTemplateRow.org
})
};
const fields = {
title: new OO.ui.FieldLayout(inputs.title, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.entry.title.label'),
align: 'top',
help: mw.msg('deputy.ante.backwardsCopy.entry.title.help')
}),
date: new OO.ui.FieldLayout(inputs.date, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.entry.date.label'),
align: 'left',
help: mw.msg('deputy.ante.backwardsCopy.entry.date.help')
}),
author: new OO.ui.FieldLayout(inputs.author, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.entry.author.label'),
align: 'left',
help: mw.msg('deputy.ante.backwardsCopy.entry.author.help')
}),
url: new OO.ui.FieldLayout(inputs.url, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.entry.url.label'),
align: 'left',
help: mw.msg('deputy.ante.backwardsCopy.entry.url.help')
}),
org: new OO.ui.FieldLayout(inputs.org, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.entry.org.label'),
align: 'left',
help: mw.msg('deputy.ante.backwardsCopy.entry.org.help')
})
};
for (const _field in inputs) {
const field = _field;
const input = inputs[field];
input.on('change', (value) => {
this.backwardsCopyTemplateRow[field] = value;
this.backwardsCopyTemplateRow.parent.save();
});
if (input instanceof OO.ui.TextInputWidget) {
// Rechecks the validity of the field.
input.setValidityFlag();
}
}
return getObjectValues(fields);
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('parameter')
.setLevel(1)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new BackwardsCopyTemplateRowPage.
*
* @param config Configuration to be passed to the element.
* @return A BackwardsCopyTemplateRowPage object
*/
function BackwardsCopyTemplateRowPage (config) {
if (!InternalBackwardsCopyTemplateRowPage) {
initBackwardsCopyTemplateRowPage();
}
return new InternalBackwardsCopyTemplateRowPage(config);
}
const backwardsCopyTemplateRowParameters = [
'title', 'year', 'author', 'authorlist',
'display_authors', 'url', 'org', 'monthday',
'articlename', 'date'
];
/**
* Represents a row/entry in a {{copied}} template.
*/
class BackwardsCopyTemplateRow extends AttributionNoticeRow {
// noinspection JSDeprecatedSymbols
/**
* Creates a new RawBackwardsCopyRow
*
* @param rowObjects
* @param parent
*/
constructor(rowObjects, parent) {
super(parent);
this.articlename = rowObjects.articlename;
this.title = rowObjects.title;
this.year = rowObjects.year;
this.author = rowObjects.author;
this.authorlist = rowObjects.authorlist;
// eslint-disable-next-line camelcase
this.display_authors = rowObjects.display_authors;
this.url = rowObjects.url;
this.org = rowObjects.org;
this.date = rowObjects.date;
this.monthday = rowObjects.monthday;
}
/**
* @inheritDoc
*/
clone(parent) {
return super.clone(parent);
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return BackwardsCopyTemplateRowPage({
backwardsCopyTemplateRow: this,
parent: dialog
});
}
}
/**
* Displayed when the actively-edited notice is in a demonstration mode or `nocat` mode.
*
* @param nocat
* @return HTML element
*/
function DemoTemplateMessage (nocat = false) {
return h_1("span", null,
h_1("b", null, mw.message(nocat ? 'deputy.ante.nocat.head' : 'deputy.ante.demo.head').parseDom().get()),
h_1("br", null),
mw.message(nocat ? 'deputy.ante.nocat.help' : 'deputy.ante.demo.help').parseDom().get(),
h_1("br", null),
h_1("span", { class: "cte-message-button" }));
}
let InternalBackwardsCopyTemplatePage;
/**
* UI representation of a {{backwards copy}} template. This representation is further broken
* down with `BackwardsCopyTemplateRowPage`, which represents each row on the template.
*
* Note that "Page" in the class title does not refer to a MediaWiki page, but rather
* a OOUI PageLayout.
*/
function initBackwardsCopyTemplatePage() {
InternalBackwardsCopyTemplatePage = class BackwardsCopyTemplatePage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { backwardsCopyTemplate, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (BackwardsCopyTemplateEditorDialog) is required');
}
else if (backwardsCopyTemplate == null) {
throw new Error('Reference template (BackwardsCopyTemplate) is required');
}
const label = mw.message('deputy.ante.backwardsCopy.label', config.backwardsCopyTemplate.name).text();
const finalConfig = {
label: label,
classes: ['cte-page-template']
};
super(backwardsCopyTemplate.id, finalConfig);
/**
* All child pages of this BackwardsCopyTemplatePage. Garbage collected when rechecked.
*/
this.childPages = new Map();
this.document = config.backwardsCopyTemplate.parsoid;
this.backwardsCopyTemplate = config.backwardsCopyTemplate;
this.parent = config.parent;
this.label = label;
backwardsCopyTemplate.addEventListener('rowAdd', () => {
parent.rebuildPages();
});
backwardsCopyTemplate.addEventListener('rowDelete', () => {
parent.rebuildPages();
});
backwardsCopyTemplate.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.$element.append(this.renderButtons(), this.renderHeader(), renderMergePanel('backwardsCopy', this.backwardsCopyTemplate, this.mergeButton), this.renderBotPanel(), this.renderDemoPanel(), renderPreviewPanel(this.backwardsCopyTemplate), this.renderTemplateOptions());
}
/**
* @inheritDoc
*/
getChildren() {
const rows = this.backwardsCopyTemplate.rows;
const rowPages = [];
for (const row of rows) {
if (!this.childPages.has(row)) {
this.childPages.set(row, row.generatePage(this.parent));
}
rowPages.push(this.childPages.get(row));
}
// Delete deleted rows from cache.
this.childPages.forEach((page, row) => {
if (rowPages.indexOf(page) === -1) {
this.childPages.delete(row);
}
});
return rowPages;
}
/**
* @return The rendered header of this PageLayout.
*/
renderHeader() {
return h_1("h3", null, this.label);
}
/**
* Renders the set of buttons that appear at the top of the page.
*
* @return A <div> element.
*/
renderButtons() {
const buttonSet = h_1("div", { style: { float: 'right' } });
this.mergeButton = new OO.ui.ButtonWidget({
icon: 'tableMergeCells',
title: mw.msg('deputy.ante.merge'),
framed: false
});
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.copied.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
if (this.backwardsCopyTemplate.rows.length > 0) {
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.copied.remove.confirm', `${this.backwardsCopyTemplate.rows.length}`).text()).done((confirmed) => {
if (confirmed) {
this.backwardsCopyTemplate.destroy();
}
});
}
else {
this.backwardsCopyTemplate.destroy();
}
});
const addButton = new OO.ui.ButtonWidget({
flags: ['progressive'],
icon: 'add',
label: mw.msg('deputy.ante.copied.add')
});
addButton.on('click', () => {
this.backwardsCopyTemplate.addRow(new BackwardsCopyTemplateRow({}, this.backwardsCopyTemplate));
});
buttonSet.appendChild(unwrapWidget(this.mergeButton));
buttonSet.appendChild(unwrapWidget(deleteButton));
buttonSet.appendChild(unwrapWidget(addButton));
return buttonSet;
}
/**
* Renders a panel that shows when a bot is used.
*
* @return An unwrapped OOUI MessageWidget
*/
renderBotPanel() {
if (this.backwardsCopyTemplate.node.hasParameter('bot')) {
const bot = this.backwardsCopyTemplate.node.getParameter('bot');
return unwrapWidget(DeputyMessageWidget({
type: 'notice',
icon: 'robot',
label: new OO.ui.HtmlSnippet(mw.message('deputy.ante.backwardsCopy.bot', bot).parse()),
closable: true
}));
}
else {
return null;
}
}
/**
* Renders a panel that shows when demo mode is enabled.
*
* @return An unwrapped OOUI MessageWidget
*/
renderDemoPanel() {
if (this.backwardsCopyTemplate.node.hasParameter('bot')) {
// Insert element directly into widget (not as text, or else event
// handlers will be destroyed).
const messageBox = DeputyMessageWidget({
type: 'notice',
icon: 'alert',
label: new OO.ui.HtmlSnippet(DemoTemplateMessage().innerHTML),
closable: true
});
const clearButton = new OO.ui.ButtonWidget({
flags: ['progressive', 'primary'],
label: mw.msg('deputy.ante.demo.clear')
});
clearButton.on('click', () => {
this.backwardsCopyTemplate.node.removeParameter('demo');
removeElement(unwrapWidget(messageBox));
});
swapElements(unwrapWidget(messageBox)
.querySelector('.cte-message-button'), unwrapWidget(clearButton));
return unwrapWidget(messageBox);
}
else {
return null;
}
}
/**
* Renders the panel used to merge multiple {{copied}} templates.
*
* @return A <div> element
*/
renderMergePanel() {
return renderMergePanel('backwardsCopy', this.backwardsCopyTemplate, this.mergeButton);
}
/**
* Renders the global options of this template. This includes parameters that are not
* counted towards an entry and affect the template as a whole.
*
* @return A <div> element.
*/
renderTemplateOptions() {
var _a, _b;
const inputSet = {
comments: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.backwardsCopy.comments.placeholder'),
value: (_a = this.backwardsCopyTemplate.comments) === null || _a === void 0 ? void 0 : _a.trim()
}),
id: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ante.backwardsCopy.id.placeholder'),
value: (_b = this.backwardsCopyTemplate.revid) === null || _b === void 0 ? void 0 : _b.trim()
})
};
const fields = {
comments: new OO.ui.FieldLayout(inputSet.comments, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.comments.label'),
help: mw.msg('deputy.ante.backwardsCopy.comments.help'),
align: 'top'
}),
id: new OO.ui.FieldLayout(inputSet.id, {
$overlay: this.parent.$overlay,
label: mw.msg('deputy.ante.backwardsCopy.id.label'),
help: mw.msg('deputy.ante.backwardsCopy.id.help'),
align: 'top'
})
};
inputSet.comments.on('change', (value) => {
this.backwardsCopyTemplate.comments = value.trim();
this.backwardsCopyTemplate.save();
});
inputSet.id.on('change', (value) => {
this.backwardsCopyTemplate.revid = value.trim();
this.backwardsCopyTemplate.save();
});
return h_1("div", { class: "cte-templateOptions" },
h_1("div", { style: { marginRight: '8px' } }, unwrapWidget(fields.comments)),
h_1("div", { style: { flex: '0.5' } }, unwrapWidget(fields.id)));
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
/** @member any */
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('puzzle')
.setLevel(0)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new BackwardsCopyTemplatePage.
*
* @param config Configuration to be passed to the element.
* @return A BackwardsCopyTemplatePage object
*/
function BackwardsCopyTemplatePage (config) {
if (!InternalBackwardsCopyTemplatePage) {
initBackwardsCopyTemplatePage();
}
return new InternalBackwardsCopyTemplatePage(config);
}
/**
* Represents a single {{copied}} template in the Parsoid document.
*/
class BackwardsCopyTemplate extends RowedAttributionNotice {
/**
* @return This template's rows.
*/
get rows() {
return this._rows;
}
/**
* Parses parameters into class properties. This WILL destroy unknown
* parameters and parameters in the incorrect order!
*
* This function does not modify the template data.
*/
parse() {
if (this.node.getParameter('demo')) {
this.demo = this.node.getParameter('demo');
}
if (this.node.getParameter('comments')) {
this.comments = this.node.getParameter('comments');
}
if (this.node.getParameter('id')) {
this.revid = this.node.getParameter('id');
}
// Extract {{backwards copy}} rows.
const rows = [];
// Numberless
if (this.hasRowParameters(backwardsCopyTemplateRowParameters)) {
// If `from`, `to`, ..., or `merge` is found.
rows.push(new BackwardsCopyTemplateRow(this.extractRowParameters(backwardsCopyTemplateRowParameters), this));
}
// Numbered
let i = 1, continueExtracting = true;
do {
if (this.hasRowParameters(backwardsCopyTemplateRowParameters, i)) {
rows.push(new BackwardsCopyTemplateRow(this.extractRowParameters(backwardsCopyTemplateRowParameters, i), this));
}
else if (!(i === 1 && rows.length > 0)) {
// Row doesn't exist. Stop parsing from here.
continueExtracting = false;
}
i++;
} while (continueExtracting);
/**
* All the rows of this template.
*
* @type {BackwardsCopyTemplateRow[]}
*/
this._rows = rows;
}
/**
* Saves the current template data to the Parsoid element.
*/
save() {
this.node.removeParameter('bot');
if (this.demo) {
this.node.setParameter('demo', this.demo);
}
this.node.setParameter('comments', this.comments);
this.node.setParameter('id', this.revid);
const existingParameters = this.node.getParameters();
for (const param in existingParameters) {
if (backwardsCopyTemplateRowParameters.some((v) => param.startsWith(v))) {
// This is a row parameter. Remove it in preparation for rebuild (further below).
this.node.removeParameter(param);
}
}
if (this._rows.length === 1) {
// If there is only one row, don't bother with numbered rows.
for (const param of backwardsCopyTemplateRowParameters) {
if (this._rows[0][param] !== undefined) {
this.node.setParameter(param, this._rows[0][param]);
}
}
}
else {
// If there are multiple rows, add number suffixes (except for i = 0).
for (let i = 0; i < this._rows.length; i++) {
for (const param of backwardsCopyTemplateRowParameters) {
if (this._rows[i][param] !== undefined) {
this.node.setParameter(param + (i === 0 ? '' : i + 1), this._rows[i][param]);
}
}
}
}
this.dispatchEvent(new Event('save'));
}
/**
* Destroys this template completely.
*/
destroy() {
this.node.destroy();
// Self-destruct
Object.keys(this).forEach((k) => delete this[k]);
this.dispatchEvent(new Event('destroy'));
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return BackwardsCopyTemplatePage({
backwardsCopyTemplate: this,
parent: dialog
});
}
/**
* Copies in the rows of another {@link BackwardsCopyTemplate}, and
* optionally deletes that template or clears its contents.
*
* @param template The template to copy from.
* @param options Options for this merge.
* @param options.delete
* Whether the reference template will be deleted after merging.
* @param options.clear
* Whether the reference template's rows will be cleared after merging.
*/
merge(template, options = {}) {
if (template.rows === undefined || template === this) {
// Deleted or self
return;
}
for (const row of template.rows) {
if (options.clear) {
row.parent = this;
}
else {
this.addRow(row.clone(this));
}
}
if (options.delete) {
template.destroy();
}
}
}
let InternalTranslatedPageTemplatePage;
/**
* Initializes the process element.
*/
function initTranslatedPageTemplatePage() {
InternalTranslatedPageTemplatePage = class TranslatedPageTemplatePage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
const { translatedPageTemplate, parent } = config;
if (parent == null) {
throw new Error('Parent dialog (CopiedTemplateEditorDialog) is required');
}
else if (translatedPageTemplate == null) {
throw new Error('Reference template (TranslatedPageTemplate) is required');
}
const finalConfig = {
classes: ['cte-page-template']
};
super(translatedPageTemplate.id, finalConfig);
this.document = translatedPageTemplate.parsoid;
this.translatedPageTemplate = translatedPageTemplate;
this.parent = config.parent;
this.refreshLabel();
translatedPageTemplate.addEventListener('destroy', () => {
parent.rebuildPages();
});
this.$element.append(this.renderButtons(), this.renderHeader(), renderPreviewPanel(this.translatedPageTemplate), this.renderTemplateOptions());
}
/**
* Refreshes the page's label
*/
refreshLabel() {
this.label = mw.message('deputy.ante.translatedPage.label', this.translatedPageTemplate.lang || '??', this.translatedPageTemplate.page || '???').text();
if (this.outlineItem) {
this.outlineItem.setLabel(this.label);
}
}
/**
* Renders the set of buttons that appear at the left of the page.
*
* @return A <div> element.
*/
renderButtons() {
const copyButton = new OO.ui.ButtonWidget({
icon: 'quotes',
title: mw.msg('deputy.ante.translatedPage.copy'),
framed: false
});
copyButton.on('click', () => {
// TODO: Find out a way to l10n-ize this.
let attributionString = `[[WP:PATT|Attribution]]: Content translated from [[:${this.translatedPageTemplate.lang}:`;
let lacking = false;
if (this.translatedPageTemplate.page != null &&
this.translatedPageTemplate.page.length !== 0) {
attributionString += `${this.translatedPageTemplate.page}]]`;
}
else {
lacking = true;
if (this.translatedPageTemplate.version != null) {
attributionString += `|from a page on ${this.translatedPageTemplate.lang}.wikipedia]]`;
}
}
if (this.translatedPageTemplate.version != null) {
attributionString += ` as of revision [[:${this.translatedPageTemplate.lang}:Special:Diff/${this.translatedPageTemplate.version}|${this.translatedPageTemplate.version}]]`;
}
if (this.translatedPageTemplate.insertversion != null &&
this.translatedPageTemplate.insertversion.length !== 0) {
attributionString += ` with [[Special:Diff/${this.translatedPageTemplate.insertversion}|this edit]] et seq.`;
}
if (this.translatedPageTemplate.page != null &&
this.translatedPageTemplate.page.length !== 0) {
attributionString += `; refer to that page's [[:${this.translatedPageTemplate.lang}:Special:PageHistory/${this.translatedPageTemplate.page}|edit history]] for additional attribution`;
}
attributionString += '.';
copyToClipboard(attributionString);
if (lacking) {
mw.notify(mw.msg('deputy.ante.translatedPage.copy.lacking'), { title: mw.msg('deputy.ante'), type: 'warn' });
}
else {
mw.notify(mw.msg('deputy.ante.translatedPage.copy.success'), { title: mw.msg('deputy.ante') });
}
});
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
title: mw.msg('deputy.ante.translatedPage.remove'),
framed: false,
flags: ['destructive']
});
deleteButton.on('click', () => {
this.translatedPageTemplate.destroy();
});
return h_1("div", { style: { float: 'right' } },
unwrapWidget(copyButton),
unwrapWidget(deleteButton));
}
/**
* @return The rendered header of this PageLayout.
*/
renderHeader() {
return h_1("h3", null, this.label);
}
/**
* @return The options for this template
*/
renderTemplateOptions() {
var _a;
const layout = new OO.ui.FieldsetLayout({
icon: 'parameter',
label: mw.msg('deputy.ante.templateOptions'),
classes: ['cte-fieldset']
});
const searchApi = new mw.ForeignApi(mw.util.wikiScript('api'), {
anonymous: true
});
const inputs = {
lang: new OO.ui.TextInputWidget({
required: true,
value: this.translatedPageTemplate.lang,
placeholder: mw.msg('deputy.ante.translatedPage.lang.placeholder'),
validate: /^[a-z\d-]+$/gi
}),
page: new mw.widgets.TitleInputWidget({
$overlay: this.parent.$overlay,
api: searchApi,
required: true,
value: this.translatedPageTemplate.page || '',
placeholder: mw.msg('deputy.ante.translatedPage.page.placeholder')
}),
comments: new OO.ui.TextInputWidget({
value: this.translatedPageTemplate.comments,
placeholder: mw.msg('deputy.ante.translatedPage.comments.placeholder')
}),
version: new OO.ui.TextInputWidget({
value: this.translatedPageTemplate.version,
placeholder: mw.msg('deputy.ante.translatedPage.version.placeholder'),
validate: /^\d+$/gi
}),
insertversion: new OO.ui.TextInputWidget({
value: this.translatedPageTemplate.insertversion,
placeholder: mw.msg('deputy.ante.translatedPage.insertversion.placeholder'),
validate: /^[\d/]+$/gi
}),
section: new OO.ui.TextInputWidget({
value: this.translatedPageTemplate.section,
placeholder: mw.msg('deputy.ante.translatedPage.section.placeholder')
}),
small: new OO.ui.CheckboxInputWidget({
selected: yesNo((_a = this.translatedPageTemplate.small) !== null && _a !== void 0 ? _a : 'yes')
}),
partial: new OO.ui.CheckboxInputWidget({
selected: !!this.translatedPageTemplate.partial
})
};
const fieldLayouts = {
lang: new OO.ui.FieldLayout(inputs.lang, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.translatedPage.lang.label'),
help: mw.msg('deputy.ante.translatedPage.lang.help')
}),
page: new OO.ui.FieldLayout(inputs.page, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.translatedPage.page.label'),
help: mw.msg('deputy.ante.translatedPage.page.help')
}),
comments: new OO.ui.FieldLayout(inputs.comments, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.translatedPage.comments.label'),
help: mw.msg('deputy.ante.translatedPage.comments.help')
}),
version: new OO.ui.FieldLayout(inputs.version, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.translatedPage.version.label'),
help: mw.msg('deputy.ante.translatedPage.version.help')
}),
insertversion: new OO.ui.FieldLayout(inputs.insertversion, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.translatedPage.insertversion.label'),
help: mw.msg('deputy.ante.translatedPage.insertversion.help')
}),
section: new OO.ui.FieldLayout(inputs.section, {
$overlay: this.parent.$overlay,
align: 'left',
label: mw.msg('deputy.ante.translatedPage.section.label'),
help: mw.msg('deputy.ante.translatedPage.section.help')
}),
small: new OO.ui.FieldLayout(inputs.small, {
$overlay: this.parent.$overlay,
align: 'inline',
label: mw.msg('deputy.ante.translatedPage.small.label'),
help: mw.msg('deputy.ante.translatedPage.small.help')
}),
partial: new OO.ui.FieldLayout(inputs.partial, {
$overlay: this.parent.$overlay,
align: 'inline',
label: mw.msg('deputy.ante.translatedPage.partial.label'),
help: mw.msg('deputy.ante.translatedPage.partial.help')
})
};
for (const _field in inputs) {
const field = _field;
const input = inputs[field];
// Attach the change listener
input.on('change', (value) => {
if (input instanceof OO.ui.CheckboxInputWidget) {
this.translatedPageTemplate[field] = value ? 'yes' : 'no';
}
else {
this.translatedPageTemplate[field] =
typeof value === 'string' ? value.trim() : value;
}
this.translatedPageTemplate.save();
});
if (input instanceof OO.ui.TextInputWidget) {
// Rechecks the validity of the field.
input.setValidityFlag();
}
}
inputs.lang.on('change', (value) => {
this.refreshLabel();
if (!/^[a-z\d-]+$/gi.test(value)) {
return;
}
searchApi.apiUrl = searchApi.defaults.ajax.url =
'//' + value + '.wikipedia.org/w/api.php';
});
inputs.page.on('change', () => {
this.refreshLabel();
});
if (this.translatedPageTemplate.lang) {
searchApi.apiUrl = searchApi.defaults.ajax.url =
'//' + this.translatedPageTemplate.lang + '.wikipedia.org/w/api.php';
}
layout.addItems(getObjectValues(fieldLayouts));
return unwrapWidget(layout);
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem
.setMovable(true)
.setRemovable(true)
.setIcon('puzzle')
.setLevel(0)
.setLabel(this.label);
}
}
};
}
/**
* Creates a new TranslatedPageTemplatePage.
*
* @param config Configuration to be passed to the element.
* @return A TranslatedPageTemplatePage object
*/
function TranslatedPageTemplatePage (config) {
if (!InternalTranslatedPageTemplatePage) {
initTranslatedPageTemplatePage();
}
return new InternalTranslatedPageTemplatePage(config);
}
/**
* Represents a single {{merged-from}} template in the Parsoid document.
*/
class TranslatedPageTemplate extends AttributionNotice {
/**
* @inheritDoc
*/
parse() {
this.lang = this.node.getParameter('1');
this.page = this.node.getParameter('2');
this.comments = this.node.getParameter('3');
this.version = this.node.getParameter('version');
this.insertversion = this.node.getParameter('insertversion');
this.section = this.node.getParameter('section');
this.small = this.node.getParameter('small');
this.partial = this.node.getParameter('partial');
}
/**
* @inheritDoc
*/
save() {
this.node.setParameter('1', this.lang);
this.node.setParameter('2', this.page);
this.node.setParameter('3', this.comments);
this.node.setParameter('version', this.version);
this.node.setParameter('insertversion', this.insertversion);
this.node.setParameter('section', this.section);
if (this.small !== undefined) {
this.node.setParameter('small', yesNo(this.small) ? null : 'no');
}
if (this.partial !== undefined) {
this.node.setParameter('partial', yesNo(this.partial) ? 'yes' : null);
}
this.dispatchEvent(new Event('save'));
}
/**
* @inheritDoc
*/
destroy() {
this.node.destroy();
// Self-destruct
Object.keys(this).forEach((k) => delete this[k]);
this.dispatchEvent(new Event('destroy'));
}
/**
* @inheritDoc
*/
generatePage(dialog) {
return TranslatedPageTemplatePage({
translatedPageTemplate: this,
parent: dialog
});
}
}
/**
* An object mapping notice types to their expected on-wiki page titles.
*/
const attributionNoticeTemplatePages = {
copied: 'Copied',
splitArticle: 'Split article',
mergedFrom: 'Merged-from',
mergedTo: 'Merged-to',
backwardsCopy: 'Backwards copy',
translatedPage: 'Translated page'
};
/**
* This class contains functions, utilities, and other variables that assist in connecting
* attribution notice templates on-wiki and converting them into their AttributionNotice
* counterparts.
*/
class WikiAttributionNotices {
/**
* Initializes.
*/
static init() {
return __awaiter(this, void 0, void 0, function* () {
const attributionNoticeTemplates = {};
const templateAliasCache = {};
for (const key of Object.keys(attributionNoticeTemplatePages)) {
attributionNoticeTemplates[key] = new mw.Title(attributionNoticeTemplatePages[key], nsId('template'));
templateAliasCache[key] = [attributionNoticeTemplates[key]];
}
this.attributionNoticeTemplates = attributionNoticeTemplates;
this.templateAliasCache = templateAliasCache;
// templateAliasCache setup
const aliasRequest = yield MwApi.action.get({
action: 'query',
format: 'json',
prop: 'linkshere',
titles: getObjectValues(this.attributionNoticeTemplates)
.map((v) => v.getPrefixedText())
.join('|'),
lhprop: 'title',
lhnamespace: nsId('template'),
lhshow: 'redirect',
lhlimit: '500'
});
const aliasRequestRedirects = toRedirectsObject(aliasRequest.query.redirects);
for (const page of aliasRequest.query.pages) {
let cacheKey;
// Find the key of this page in the list of attribution notice templates.
// Slightly expensive, but this init should only be run once anyway.
for (const key in this.attributionNoticeTemplates) {
const templatePage = this.attributionNoticeTemplates[key].getPrefixedText();
if (
// Page is a perfect match.
templatePage === page.title ||
// If the page was moved, and the page originally listed above is a redirect.
// This checks if the resolved redirect matches the input page.
aliasRequestRedirects[templatePage] === page.title) {
cacheKey = key;
break;
}
}
if (!cacheKey) {
// Unexpected key not found. Page must have been moved or modified.
// Give up here.
continue;
}
const links = page.linkshere.map((v) => new mw.Title(v.title));
this.templateAliasCache[cacheKey].push(...links);
}
// templateAliasKeymap setup
this.templateAliasKeymap = {};
for (const noticeType in this.templateAliasCache) {
for (const title of this.templateAliasCache[noticeType]) {
this.templateAliasKeymap[title.getPrefixedDb()] = noticeType;
}
}
// templateAliasRegExp setup
const summarizedTitles = [];
for (const titles of getObjectValues(this.templateAliasCache)) {
for (const title of titles) {
summarizedTitles.push(title.getPrefixedDb());
}
}
this.templateAliasRegExp = new RegExp(summarizedTitles.map((v) => `(${mw.util.escapeRegExp(v)})`).join('|'), 'g');
});
}
/**
* Get the notice type of a given template from its href string, or `undefined` if it
* is not a valid notice.
*
* @param href The href of the template.
* @return A notice type string.
*/
static getTemplateNoticeType(href) {
return this.templateAliasKeymap[href.replace(/^\.\//, '')];
}
}
/**
* An object mapping notice types to their respective class.
*/
WikiAttributionNotices.attributionNoticeClasses = {
copied: CopiedTemplate,
splitArticle: SplitArticleTemplate,
mergedFrom: MergedFromTemplate,
mergedTo: MergedToTemplate,
backwardsCopy: BackwardsCopyTemplate,
translatedPage: TranslatedPageTemplate
};
/**
* Renders a MenuLayout responsible for displaying analysis options or tools.
*/
class AttributionNoticeAddMenu {
/**
* @param document
* @param baseWidget
*/
constructor(document, baseWidget) {
this.document = document;
this.baseWidget = baseWidget;
}
/**
* @inheritDoc
*/
render() {
const menuItems = new Map();
const menuSelectWidget = new OO.ui.MenuSelectWidget({
hideWhenOutOfView: false,
verticalPosition: 'below',
horizontalPosition: 'start',
widget: this.baseWidget,
$floatableContainer: this.baseWidget.$element,
items: Object.keys(attributionNoticeTemplatePages).map((key) => {
const item = new OO.ui.MenuOptionWidget({
data: key,
icon: 'add',
// Will automatically use template name as
// provided by WikiAttributionNotices.
label: `{{${attributionNoticeTemplatePages[key]}}}`,
flags: ['progressive']
});
menuItems.set(item, key);
return item;
})
});
menuSelectWidget.on('select', () => {
// Not a multiselect menu; cast the result to OptionWidget.
const selected = menuSelectWidget.findSelectedItem();
if (selected) {
const type = selected.getData();
const spot = this.document.findNoticeSpot(type);
this.document.insertNewNotice(type, spot);
// Clear selections.
menuSelectWidget.selectItem();
}
});
// Disables clipping (allows the menu to be wider than the button)
menuSelectWidget.toggleClipping(false);
this.baseWidget.on('click', () => {
menuSelectWidget.toggle(true);
});
return unwrapWidget(menuSelectWidget);
}
}
let InternalAttributionNoticesEmptyPage;
/**
* Initializes the process element.
*/
function initAttributionNoticesEmptyPage() {
InternalAttributionNoticesEmptyPage = class AttributionNoticesEmptyPage extends OO.ui.PageLayout {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
super('cte-no-templates', {});
this.parent = config.parent;
this.parsoid = config.parsoid;
const addListener = this.parent.layout.on('add', () => {
for (const name of Object.keys(this.parent.layout.pages)) {
if (name !== 'cte-no-templates' && this.outlineItem !== null) {
// Pop this page out if a page exists.
this.parent.layout.removePages([this]);
this.parent.layout.off(addListener);
return;
}
}
});
// Render the page.
const add = new OO.ui.ButtonWidget({
icon: 'add',
label: mw.msg('deputy.ante.empty.add'),
flags: ['progressive']
});
this.parent.$overlay.append(new AttributionNoticeAddMenu(this.parsoid, add).render());
this.$element.append(h_1("h3", null, mw.msg('deputy.ante.empty.header')), h_1("p", null, mw.message(this.parsoid.originalCount > 0 ?
'deputy.ante.empty.removed' :
'deputy.ante.empty.none').text()), add.$element);
}
/**
* Sets up the outline item of this page. Used in the BookletLayout.
*/
setupOutlineItem() {
if (this.outlineItem !== undefined) {
this.outlineItem.toggle(false);
}
}
};
}
/**
* Creates a new AttributionNoticesEmptyPage.
*
* @param config Configuration to be passed to the element.
* @return A AttributionNoticesEmptyPage object
*/
function CopiedTemplatesEmptyPage (config) {
if (!InternalAttributionNoticesEmptyPage) {
initAttributionNoticesEmptyPage();
}
return new InternalAttributionNoticesEmptyPage(config);
}
var ParsoidDocument_module = {};
Object.defineProperty(ParsoidDocument_module, "__esModule", { value: true });
// ParsoidDocument:start
/**
* The root of this wiki's RestBase endpoint. This MUST NOT end with a slash.
*/
const restBaseRoot = window.restBaseRoot || '/api/rest_';
/**
* Encodes text for an API parameter. This performs both an encodeURIComponent
* and a string replace to change spaces into underscores.
* @param {string} text
* @returns {string}
*/
function encodeAPIComponent(text) {
return encodeURIComponent(text.replace(/ /g, '_'));
}
/**
* Clones a regular expression.
* @param regex The regular expression to clone.
* @returns A new regular expression object.
*/
function cloneRegex(regex) {
return new RegExp(regex.source, regex.flags);
}
/**
* A class denoting a transclusion template node (a transcluded template, barring any included
* text or inline parameters) inside an element with [typeof="mw:Transclusion"].
*/
class ParsoidTransclusionTemplateNode {
/**
* Creates a new ParsoidTransclusionTemplateNode. Can be used later on to add a template
* into wikitext. To have this node show up in wikitext, append the node's element (using
* {@link ParsoidTransclusionTemplateNode.element}) to the document of a ParsoidDocument.
* @param document The document used to generate this node.
* @param template The template to create. If you wish to generate wikitext as a block-type
* transclusion (as long as a format is not provided through TemplateData), append a "\n"
* to the end of the template name.
* @param parameters The parameters to the template.
* @param autosave
* @returns A new ParsoidTransclusionTemplateNode.
*/
static fromNew(document, template, parameters, autosave) {
const el = document.getDocument().createElement('span');
const target = { wt: template };
if (mw === null || mw === void 0 ? void 0 : mw.Title) {
// If `mediawiki.Title` is loaded, use it.
target.href = './' + new mw.Title(target.wt, mw.config.get('wgNamespaceIds').template).getPrefixedDb();
}
const data = {
target,
params: {},
i: 0
};
for (const param in (parameters !== null && parameters !== void 0 ? parameters : {})) {
const value = parameters[param];
data.params[param] = {
wt: typeof value === 'string' ? value : value.toString()
};
}
el.setAttribute('typeof', 'mw:Transclusion');
el.setAttribute('data-mw', JSON.stringify({
parts: [{
template: data
}]
}));
return new ParsoidTransclusionTemplateNode(document, el, data, data.i, autosave);
}
/**
* Create a new ParsoidTransclusionTemplateNode.
* @param {ParsoidDocument} parsoidDocument
* The document handling this transclusion node.
* @param {HTMLElement} originalElement
* The original element where the `data-mw` of this node is found.
* @param {*} data
* The `data-mw` `part.template` of this node.
* @param {number} i
* The `i` property of this node.
* @param {boolean} autosave
* Whether to automatically save parameter and target changes or not.
*/
constructor(parsoidDocument, originalElement, data, i, autosave = true) {
this.parsoidDocument = parsoidDocument;
this.element = originalElement;
this.data = data;
this.i = i;
this.autosave = autosave;
}
/**
* Gets the target of this node.
* @returns {object} The target of this node, in wikitext and href (for links).
*/
getTarget() {
return this.data.target;
}
/**
* Sets the target of this template (in wikitext).
* @param {string} wikitext
* The target template (in wikitext, e.g. `Test/{{FULLPAGENAME}}`).
*/
setTarget(wikitext) {
this.data.target.wt = wikitext;
if (mw === null || mw === void 0 ? void 0 : mw.Title) {
// If `mediawiki.Title` is loaded, use it.
this.data.target.href = './' + new mw.Title(wikitext, mw.config.get('wgNamespaceIds').template).getPrefixedDb();
}
else {
// Likely inaccurate. Just remove it to make sent data cleaner.
delete this.data.target.href;
}
if (this.autosave) {
this.save();
}
}
/**
* Gets the parameters of this node.
* @returns {{[key:string]:{wt:string}}} The parameters of this node, in wikitext.
*/
getParameters() {
return this.data.params;
}
/**
* Checks if a template has a parameter.
* @param {string} key The key of the parameter to check.
* @returns {boolean} `true` if the template has the given parameter
*/
hasParameter(key) {
return this.data.params[key] != null;
}
/**
* Gets the value of a parameter.
* @param {string} key The key of the parameter to check.
* @returns {string} The parameter value.
*/
getParameter(key) {
var _a;
return (_a = this.data.params[key]) === null || _a === void 0 ? void 0 : _a.wt;
}
/**
* Sets the value for a specific parameter. If `value` is null or undefined,
* the parameter is removed.
* @param {string} key The parameter key to set.
* @param {string} value The new value of the parameter.
*/
setParameter(key, value) {
if (value != null) {
this.data.params[key] = { wt: value };
if (this.autosave) {
this.save();
}
}
else {
this.removeParameter(key);
}
}
/**
* Removes a parameter from the template.
* @param key The parameter key to remove.
*/
removeParameter(key) {
if (this.data.params[key] != null) {
delete this.data.params[key];
}
if (this.autosave) {
this.save();
}
}
/**
* Fix improperly-set parameters.
*/
cleanup() {
for (const key of Object.keys(this.data.params)) {
const param = this.data.params[key];
if (typeof param === 'string') {
this.data.params[key] = {
wt: param
};
}
}
}
/**
* Removes this node from its element. This will prevent the node from being saved
* again.
* @param eraseLine For block templates. Setting this to `true` will also erase a newline
* that immediately succeeds this template, if one exists. This is useful in ensuring that
* there are no excesses of newlines in the document.
*/
destroy(eraseLine) {
var _a;
const existingData = JSON.parse(this.element.getAttribute('data-mw'));
if (existingData.parts.length === 1) {
const nodeElements = this.parsoidDocument.getNodeElements(this);
const succeedingTextNode = (_a = nodeElements[nodeElements.length - 1]) === null || _a === void 0 ? void 0 : _a.nextSibling;
// The element contains nothing else except this node. Destroy the element entirely.
this.parsoidDocument.destroyParsoidNode(this.element);
if (eraseLine && succeedingTextNode &&
succeedingTextNode.nodeType === Node.TEXT_NODE) {
// Erase a starting newline, if one exists
succeedingTextNode.nodeValue = succeedingTextNode.nodeValue
.replace(/^\n/, '');
}
}
else {
const partToRemove = existingData.parts.find((part) => { var _a; return ((_a = part.template) === null || _a === void 0 ? void 0 : _a.i) === this.i; });
if (eraseLine) {
const iFront = existingData.parts.indexOf(partToRemove) - 1;
const iBack = existingData.parts.indexOf(partToRemove) + 1;
let removed = false;
if (iBack < existingData.parts.length &&
typeof existingData.parts[iBack] === 'string') {
// Attempt to remove whitespace from the string in front of the template.
if (/^\r?\n/.test(existingData.parts[iBack])) {
// Whitespace found, remove it.
existingData.parts[iBack] =
existingData.parts[iBack].replace(/^\r?\n/, '');
removed = true;
}
}
if (!removed && iFront > -1 && typeof existingData.parts[iFront] === 'string') {
// Attempt to remove whitespace from the string behind the template.
if (/\r?\n$/.test(existingData.parts[iFront])) {
// Whitespace found, remove it.
existingData.parts[iFront] =
existingData.parts[iFront].replace(/\r?\n$/, '');
}
}
}
existingData.parts.splice(existingData.parts.indexOf(partToRemove), 1);
this.element.setAttribute('data-mw', JSON.stringify(existingData));
}
}
/**
* Saves this node (including modifications) back into its element.
*/
save() {
this.cleanup();
const existingData = JSON.parse(this.element.getAttribute('data-mw'));
existingData.parts.find((part) => { var _a; return ((_a = part.template) === null || _a === void 0 ? void 0 : _a.i) === this.i; }).template = this.data;
this.element.setAttribute('data-mw', JSON.stringify(existingData));
}
}
/**
* A class containing an {@link HTMLIFrameElement} along with helper functions
* to make manipulation easier.
*/
class ParsoidDocument extends EventTarget {
/**
* Create a new ParsoidDocument instance from a page on-wiki.
* @param {string} page The page to load.
* @param {object} options Options for frame loading.
* @param {boolean} options.reload
* Whether the current page should be discarded and reloaded.
* @param options.allowMissing
* Set to `false` to avoid loading a blank document if the page does not exist.
*/
static async fromPage(page, options = {}) {
const doc = new ParsoidDocument();
await doc.loadPage(page, options);
return doc;
}
/**
* Create a new ParsoidDocument instance from plain HTML.
* @param {string} page The name of the page.
* @param {string} html The HTML to use.
* @param restBaseUri The relative URI to the RESTBase instance to be used for transforms.
* @param {boolean} wrap Set to `false` to avoid wrapping the HTML within the body.
*/
static async fromHTML(page, html, restBaseUri, wrap = true) {
const doc = new ParsoidDocument();
await doc.loadHTML(page, wrap ? ParsoidDocument.blankDocument : html, restBaseUri);
if (wrap) {
doc.document.getElementsByTagName('body')[0].innerHTML = html;
}
return doc;
}
/**
* Creates a new ParsoidDocument from a blank page.
* @param {string} page The name of the page.
* @param restBaseUri
*/
static async fromBlank(page, restBaseUri) {
const doc = new ParsoidDocument();
await doc.loadHTML(page, ParsoidDocument.blankDocument, restBaseUri);
return doc;
}
/**
* Creates a new ParsoidDocument from wikitext.
* @param {string} page The page of the document.
* @param {string} wikitext The wikitext to load.
* @param restBaseUri
*/
static async fromWikitext(page, wikitext, restBaseUri) {
const doc = new ParsoidDocument();
await doc.loadWikitext(page, wikitext, restBaseUri);
return doc;
}
/**
* Get additional request options to be patched onto RESTBase API calls.
* Extend this class to modify this.
* @protected
*/
getRequestOptions() {
return {
headers: {
'Api-User-Agent': 'parsoid-document/2.0.0 (https://github.com/ChlodAlejandro/parsoid-document; chlod@chlod.net)'
}
};
}
/**
* @returns `true` if the page is a redirect. `false` if otherwise.
*/
get redirect() {
return this.document &&
this.document.querySelector("[rel='mw:PageProp/redirect']") !== null;
}
/**
* Create a new ParsoidDocument instance.
*/
constructor() {
super();
this.iframe = document.createElement('iframe');
Object.assign(this.iframe.style, {
width: '0',
height: '0',
border: '0',
position: 'fixed',
top: '0',
left: '0'
});
this.iframe.addEventListener('load', () => {
if (this.iframe.contentWindow.document.URL === 'about:blank') {
// Blank document loaded. Ignore.
return;
}
/**
* The document of this ParsoidDocument's IFrame.
* @type {Document}
* @protected
*/
this.document = this.iframe.contentWindow.document;
this.$document = $(this.document);
this.setupJquery(this.$document);
this.buildIndex();
if (this.observer) {
// This very much assumes that the MutationObserver is still connected.
// Yes, this is quite an assumption, but should not be a problem during normal use.
// If only MutationObserver had a `.connected` field...
this.observer.disconnect();
}
this.observer = new MutationObserver(() => {
this.buildIndex();
});
this.observer.observe(this.document.getElementsByTagName('body')[0], {
// Listen for ALL DOM mutations.
attributes: true,
childList: true,
subtree: true
});
// Replace the page title. Handles redirects.
if (this.document.title) {
this.page = (mw === null || mw === void 0 ? void 0 : mw.Title) ?
new mw.Title(this.document.title).getPrefixedText() :
this.document.title;
}
});
document.getElementsByTagName('body')[0].appendChild(this.iframe);
}
/**
* Set up a JQuery object for this window.
* @param $doc The JQuery object to set up.
* @returns The JQuery object.
*/
setupJquery($doc) {
// noinspection JSPotentiallyInvalidConstructorUsage
const $proto = $doc.constructor.prototype;
/* eslint-disable-next-line @typescript-eslint/no-this-alias */
const doc = this;
$proto.parsoidNode = function () {
if (this.length === 1) {
return doc.findParsoidNode(this[0]);
}
else {
return this.map((node) => doc.findParsoidNode(node));
}
};
$proto.parsoid = function () {
/**
* Processes an element and extracts its transclusion parts.
* @param {HTMLElement} element Element to process.
* @returns The transclusion parts.
*/
function process(element) {
const rootNode = doc.findParsoidNode(element);
const mwData = JSON.parse(rootNode.getAttribute('data-mw'));
return mwData.parts.map((part) => {
if (part.template) {
return new ParsoidTransclusionTemplateNode(this, rootNode, part.template, part.template.i);
}
else {
return part;
}
});
}
if (this.length === 1) {
return process(this[0]);
}
else {
return this.map((element) => process(element));
}
};
return $doc;
}
/**
* Notify the user of a document loading error.
* @param {Error} error An error object.
*/
notifyLoadError(error) {
if (mw === null || mw === void 0 ? void 0 : mw.notify) {
mw.notify([
(() => {
const a = document.createElement('span');
a.innerText = 'An error occurred while loading a Parsoid document: ';
return a;
})(),
(() => {
const b = document.createElement('b');
b.innerText = error.message;
return b;
})()
], {
tag: 'parsoidDocument-error',
type: 'error'
});
}
throw error;
}
/**
* Loads a wiki page with this ParsoidDocument.
* @param {string} page The page to load.
* @param {object} options Options for frame loading.
* @param {boolean} options.reload
* Whether the current page should be discarded and reloaded.
* @param options.allowMissing
* Set to `false` to avoid loading a blank document if the page does not exist.
* @param options.restBaseUri
* A relative or absolute URI to the wiki's RESTBase root. This is
* `/api/rest_` by default, though the `window.restBaseRoot` variable
* can modify it.
* @param options.requestOptions
* Options to pass to the `fetch` request.
* @param options.followRedirects
* Whether to follow page redirects or not.
*/
async loadPage(page, options = {}) {
var _a, _b, _c;
if (this.document && options.reload !== true) {
throw new Error('Attempted to reload an existing frame.');
}
this.restBaseUri = (_a = options.restBaseUri) !== null && _a !== void 0 ? _a : restBaseRoot;
return fetch(`${this.restBaseUri}v1/page/html/${encodeAPIComponent(page)}?stash=true&redirect=${options.followRedirects !== false ? 'true' : 'false'}&t=${Date.now()}`, Object.assign({
cache: 'no-cache'
}, (_b = this.getRequestOptions()) !== null && _b !== void 0 ? _b : {}, (_c = options.requestOptions) !== null && _c !== void 0 ? _c : {}))
.then((data) => {
/**
* The ETag of this iframe's content.
* @type {string}
*/
this.etag = data.headers.get('ETag');
if (data.status === 404 && options.allowMissing !== false) {
this.fromExisting = false;
// A Blob is used in order to allow cross-frame access without changing
// the origin of the frame.
return Promise.resolve(ParsoidDocument.defaultDocument);
}
else {
this.fromExisting = true;
return data.text();
}
})
.then((html) => this.loadHTML(page, html, this.restBaseUri))
.catch(this.notifyLoadError);
}
/**
* Load a document from wikitext.
* @param {string} page The page title of this document.
* @param {string} wikitext The wikitext to load.
* @param restBaseUri
*/
async loadWikitext(page, wikitext, restBaseUri) {
var _a;
this.restBaseUri = restBaseUri !== null && restBaseUri !== void 0 ? restBaseUri : restBaseRoot;
return fetch(`${this.restBaseUri}v1/transform/wikitext/to/html/${encodeAPIComponent(page)}?t=${Date.now()}`, Object.assign((_a = this.getRequestOptions()) !== null && _a !== void 0 ? _a : {}, {
cache: 'no-cache',
method: 'POST',
body: (() => {
const formData = new FormData();
formData.set('wikitext', wikitext);
formData.set('body_only', 'false');
return formData;
})()
}))
.then((data) => {
/**
* The ETag of this iframe's content.
* @type {string}
*/
this.etag = data.headers.get('ETag');
this.fromExisting = false;
return data.text();
})
.then((html) => this.loadHTML(page, html, this.restBaseUri))
.catch(this.notifyLoadError);
}
/**
* Load a document from HTML.
* @param {string} page The loaded page's name.
* @param {string} html The page's HTML.
* @param restBaseUri A relative or absolute URI to the wiki's RESTBase root.
*/
async loadHTML(page, html, restBaseUri) {
this.restBaseUri = restBaseUri !== null && restBaseUri !== void 0 ? restBaseUri : restBaseRoot;
// A Blob is used in order to allow cross-frame access without changing
// the origin of the frame.
this.iframe.src = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
this.page = page;
return new Promise((res) => {
this.iframe.addEventListener('load', () => {
res();
}, { once: true });
});
}
/**
* Destroys the frame and pops it off of the DOM (if inserted).
* Silently fails if the frame has not yet been built.
*/
destroy() {
if (this.iframe && this.iframe.parentElement) {
this.iframe.parentElement.removeChild(this.iframe);
this.iframe = undefined;
}
}
/**
* Reloads the page. This will destroy any modifications made to the document.
*/
async reload() {
const page = this.page;
this.page = undefined;
return this.loadPage(page, { reload: true });
}
/**
* Clears the frame for a future reload. This will later permit `loadPage` and
* other related functions to run without the `reload` option.
*/
reset() {
// Reset the page
this.page = undefined;
// Reset the element index
this.elementIndex = undefined;
// Reset DOM-related fields
this.document = undefined;
this.$document = undefined;
this.etag = undefined;
this.fromExisting = undefined;
// Disconnect the mutation observer
this.observer.disconnect();
this.observer = undefined;
// Reset the IFrame
this.iframe.src = 'about:blank';
// By this point, this whole thing should be a clean state.
}
/**
* Constructs the {@link ParsoidDocument#elementIndex} from the current document.
*/
buildIndex() {
if (this.document == null) {
throw new Error("Can't perform operations without a loaded page.");
}
this.elementIndex = {};
const nodes = this.document.querySelectorAll('[typeof^=\'mw:\']');
nodes.forEach((node) => {
node.getAttribute('typeof')
.split(/\s+/g)
.map((type) => type.replace(/^mw:/, ''))
.forEach((type) => {
if (this.elementIndex[type] == null) {
this.elementIndex[type] = [];
}
this.elementIndex[type].push(node);
});
});
}
/**
* Gets the `<section>` HTMLElement given a section ID.
* @param id The ID of the section
* @returns The HTMLElement of the section. If the section cannot be found, `null`.
*/
getSection(id) {
return this.document.querySelector(`section[data-mw-section-id="${id}"]`);
}
/**
* Finds a template in the loaded document.
* @param {string|RegExp} templateName The name of the template to look for.
* @param {boolean} hrefMode Use the href instead of the wikitext to search for templates.
* @returns {HTMLElement} A list of elements.
*/
findTemplate(templateName, hrefMode = false) {
var _a;
if (this.document == null) {
throw new Error("Can't perform operations without a loaded page.");
}
const templates = (_a = this.elementIndex) === null || _a === void 0 ? void 0 : _a.Transclusion;
if (templates == null || templates.length === 0) {
return [];
}
return templates.map((node) => {
const mwData = JSON.parse(node.getAttribute('data-mw'));
const matching = mwData.parts.filter((part) => {
var _a;
if (part.template == null) {
return false;
}
if (((_a = part.template.target) === null || _a === void 0 ? void 0 : _a.href) == null) {
// Parser function or magic word, not a template transclusion
return false;
}
const compareTarget = part.template.target[hrefMode ? 'href' : 'wt'];
if (typeof templateName !== 'string') {
return cloneRegex(templateName).test(compareTarget.trim());
}
else {
return templateName === compareTarget.trim();
}
});
if (matching.length > 0) {
return matching.map((part) => {
return new ParsoidTransclusionTemplateNode(this, node, part.template, part.template.i);
});
}
else {
return [];
}
}).reduce((a, b) => a.concat(b), []);
}
/**
* Finds the element with the "data-mw" attribute containing the element
* passed into the function.
* @param {HTMLElement} element
* The element to find the parent of. This must be a member of the
* ParsoidDocument's document.
* @returns {HTMLElement} The element responsible for showing the given element.
*/
findParsoidNode(element) {
let pivot = element;
while (pivot.getAttribute('about') == null) {
if (pivot.parentElement == null) {
// Dead end.
throw new Error('Reached root of DOM while looking for original Parsoid node.');
}
pivot = pivot.parentElement;
}
return this.document.querySelector(`[about="${pivot.getAttribute('about')}"][data-mw]`);
}
/**
* Get HTML elements that are associated to a specific Parsoid node using its
* `about` attribute.
* @param node The node to get the elements of
* @returns All elements that match the `about` of the given node.
*/
getNodeElements(node) {
return Array.from(this.document.querySelectorAll(`[about="${(node instanceof ParsoidTransclusionTemplateNode ? node.element : node)
.getAttribute('about')}"]`));
}
/**
* Deletes all elements that have the same `about` attribute as the given element.
* This effectively deletes an element, be it a transclusion set, file, section,
* or otherwise.
* @param element
*/
destroyParsoidNode(element) {
if (element.hasAttribute('about')) {
this.getNodeElements(element).forEach((nodeElement) => {
nodeElement.parentElement.removeChild(nodeElement);
});
}
else {
// No "about" attribute. Just remove that element only.
element.parentElement.removeChild(element);
}
}
/**
* Converts the contents of this document to wikitext.
* @returns {Promise<string>} The wikitext of this document.
*/
async toWikitext() {
var _a;
// this.restBaseUri should be set.
let target = `${this.restBaseUri}v1/transform/html/to/wikitext/${encodeAPIComponent(this.page)}`;
if (this.fromExisting) {
target += `/${+(/(\d+)$/.exec(this.document.documentElement.getAttribute('about'))[1])}`;
}
const requestOptions = this.getRequestOptions();
return fetch(target, Object.assign(requestOptions, {
method: 'POST',
headers: Object.assign((_a = requestOptions.headers) !== null && _a !== void 0 ? _a : {}, { 'If-Match': this.fromExisting ? this.etag : undefined }),
body: (() => {
const data = new FormData();
data.set('html', this.document.documentElement.outerHTML);
data.set('scrub_wikitext', 'true');
data.set('stash', 'true');
return data;
})()
})).then((data) => data.text());
}
/**
* Get the {@link Document} object of this ParsoidDocument.
* @returns {Document} {@link ParsoidDocument#document}
*/
getDocument() {
return this.document;
}
/**
* Get the JQuery object associated with this ParsoidDocument.
* @returns {*} {@link ParsoidDocument#$document}
*/
getJQuery() {
return this.$document;
}
/**
* Get the IFrame element of this ParsoidDocument.
* @returns {HTMLIFrameElement} {@link ParsoidDocument#iframe}
*/
getIframe() {
return this.iframe;
}
/**
* Get the page name of the currently-loaded page.
* @returns {string} {@link ParsoidDocument#page}
*/
getPage() {
return this.page;
}
/**
* Get the element index of this ParsoidDocument.
* @returns {{ [p: string]: HTMLElement[] }} {@link ParsoidDocument#elementIndex}
*/
getElementIndex() {
return this.elementIndex;
}
/**
* Check if this element exists on-wiki or not.
* @returns {boolean} {@link ParsoidDocument#fromExisting}
*/
isFromExisting() {
return this.fromExisting;
}
}
ParsoidDocument.Node = ParsoidTransclusionTemplateNode;
/**
* A blank Parsoid document, with a section 0.
* @type {string}
*/
ParsoidDocument.blankDocument = '<html><body><section data-mw-section-id="0"></section></body></html>';
/**
* The default document to create if a page was not found.
* @type {string}
*/
ParsoidDocument.defaultDocument = ParsoidDocument.blankDocument;
// ParsoidDocument:end
var _default = ParsoidDocument_module.default = ParsoidDocument;
/**
* An event dispatched when a template inside a `CopiedTemplateEditorDialog` is inserted.
*/
class TemplateInsertEvent extends Event {
/**
* @param template The template that was inserted
* @param eventInitDict
*/
constructor(template, eventInitDict) {
super('templateInsert', eventInitDict);
this.template = template;
}
}
/**
* Extension class of ParsoidDocument's node. Used to type `parsoidDocument` in the
* below function. Since the original node is always instantiated with `this`, it
* can be assumed that `parsoidDocument` is a valid CTEParsoidDocument.
*/
class CTEParsoidTransclusionTemplateNode extends _default.Node {
/**
* @inheritDoc
*/
static fromNew(document, template, parameters, autosave) {
return this.upgradeNode(super.fromNew(document, template, parameters, autosave), document);
}
/**
* Upgrades a vanilla ParsoidDocument.Node to a CTEParsoidTransclusionTemplateNode.
*
* @param node The node to upgrade
* @param document The document to attach
* @return A CTEParsoidTransclusionTemplateNode
*/
static upgradeNode(node, document) {
return new CTEParsoidTransclusionTemplateNode(document, node.element, node.data, node.i, node.autosave);
}
}
/**
* Creates blank attribution notices. Its own class to avoid circular dependencies.
*/
class TemplateFactory {
/**
* Get the template wikitext (`target.wt`) of a given notice type.
*
* @param type
* @return The wikitext of the template's target page
*/
static getTemplateWikitext(type) {
return WikiAttributionNotices.attributionNoticeTemplates[type].getNamespaceId() ===
nsId('template') ?
// If in the "Template" namespace, "Copied"
WikiAttributionNotices.attributionNoticeTemplates[type].getMainText() :
// If not in the "Template" namespace, "Namespace:Copied"
WikiAttributionNotices.attributionNoticeTemplates[type].getPrefixedText();
}
/**
* Creates a new {@link CopiedTemplate}
*
* @param document
* @return A new CopiedTemplate
*/
static copied(document) {
const templateWikitext = TemplateFactory.getTemplateWikitext('copied');
const node = CTEParsoidTransclusionTemplateNode.fromNew(document, templateWikitext, {
// Pre-fill with target page
to: new mw.Title(document.getPage()).getSubjectPage().getPrefixedText()
});
node.element.setAttribute('about', `N${TemplateFactory.noticeCount++}`);
node.element.classList.add('copiednotice');
return new CopiedTemplate(node);
}
/**
* Creates a new {@link SplitArticleTemplate}
*
* @param document
* @return A new SplitArticleTemplate
*/
static splitArticle(document) {
const templateWikitext = TemplateFactory.getTemplateWikitext('splitArticle');
const node = CTEParsoidTransclusionTemplateNode.fromNew(document, templateWikitext, {
from: new mw.Title(document.getPage()).getSubjectPage().getPrefixedText(),
// Blank string to trigger row creation
to: ''
});
node.element.setAttribute('about', `N${TemplateFactory.noticeCount++}`);
node.element.classList.add('box-split-article');
return new SplitArticleTemplate(node);
}
/**
* Creates a new {@link MergedFromTemplate}
*
* @param document
* @return A new MergedFromTemplate
*/
static mergedFrom(document) {
const templateWikitext = TemplateFactory.getTemplateWikitext('mergedFrom');
const node = CTEParsoidTransclusionTemplateNode.fromNew(document, templateWikitext, {});
node.element.setAttribute('about', `N${TemplateFactory.noticeCount++}`);
node.element.classList.add('box-merged-from');
return new MergedFromTemplate(node);
}
/**
* Creates a new {@link MergedToTemplate}
*
* @param document
* @return A new MergedToTemplate
*/
static mergedTo(document) {
const templateWikitext = TemplateFactory.getTemplateWikitext('mergedTo');
const node = CTEParsoidTransclusionTemplateNode.fromNew(document, templateWikitext, {});
node.element.setAttribute('about', `N${TemplateFactory.noticeCount++}`);
node.element.classList.add('box-merged-to');
return new MergedToTemplate(node);
}
/**
* Creates a new {@link BackwardsCopyTemplate}
*
* @param document
* @return A new MergedToTemplate
*/
static backwardsCopy(document) {
const templateWikitext = TemplateFactory.getTemplateWikitext('backwardsCopy');
const node = CTEParsoidTransclusionTemplateNode.fromNew(document, templateWikitext, {
// Blank string to trigger row creation
title: ''
});
node.element.setAttribute('about', `N${TemplateFactory.noticeCount++}`);
node.element.classList.add('box-merged-to');
return new BackwardsCopyTemplate(node);
}
/**
* Creates a new {@link TranslatedPageTemplate}
*
* @param document
* @return A new MergedToTemplate
*/
static translatedPage(document) {
const templateWikitext = TemplateFactory.getTemplateWikitext('translatedPage');
const node = CTEParsoidTransclusionTemplateNode.fromNew(document, templateWikitext);
node.element.setAttribute('about', `N${TemplateFactory.noticeCount++}`);
node.element.classList.add('box-translated-page');
return new TranslatedPageTemplate(node);
}
}
/**
* Simply increments when notices are added. This gives specific notices a
* human-friendly identifier.
*/
TemplateFactory.noticeCount = 1;
/**
* Moves a value as determined by an index of the array to the start of the array.
* Mutates the original array.
*
* @param array The array to use
* @param index The index of the value to move to the start of the array
* @return The reordered array.
*/
function moveToStart(array, index) {
const el = array[index];
array.splice(index, 1);
array.splice(0, 0, el);
return array;
}
function organize(objects, keyer) {
const finalObj = {};
for (const obj of objects) {
const key = keyer(obj);
if (!finalObj[key]) {
finalObj[key] = [];
}
finalObj[key].push(obj);
}
return finalObj;
}
/**
* An object containing an {@link HTMLIFrameElement} along with helper functions
* to make manipulation easier.
*/
class CTEParsoidDocument extends _default {
/**
* Creates a new CTE-specific ParsoidDocument. This extends from the existing
* ParsoidDocument with functions specifically catered for pages that have
* {{copied}} (or will have) templates.
*/
constructor() {
super();
/**
* A map of all Parsoid HTML elements and their attribution notices. When notices are
* detected, they are added here. ParsoidTemplateTransclusionNode is not used here
* since they are regenerated every time `findTemplate` is called.
*/
this.notices = new Map();
/**
* The original number of {{copied}} notices in the document.
*/
this.originalCount = null;
// Event listeners should be fired synchronously. Load listener found in
// `super` should have been run by this point.
this.iframe.addEventListener('load', () => {
if (this.iframe.contentWindow.document.URL === 'about:blank') {
// Blank document loaded. Ignore.
return;
}
const notices = this.findNoticeType('copied');
this.originalCount = notices.length;
if (this.redirect) {
// Move the redirect line out of the way to avoid breaking redirects.
const p = document.createElement('p');
const redirect = this.iframe.contentWindow.document
.querySelector('[rel="mw:PageProp/Redirect"]');
redirect.insertAdjacentElement('afterend', p);
p.appendChild(redirect);
}
});
}
/**
* @inheritDoc
* @protected
*/
getRequestOptions() {
var _a, _b;
const ro = super.getRequestOptions();
return {
headers: {
'Api-User-Agent': `${MwApi.USER_AGENT} ${(_b = (_a = ro.headers) === null || _a === void 0 ? void 0 : _a['Api-User-Agent']) !== null && _b !== void 0 ? _b : ''}`
}
};
}
/**
* @inheritDoc
*/
reset() {
super.reset();
this.originalCount = undefined;
this.notices.clear();
}
/**
* Finds all content attribution notices in the talk page. This includes {{copied}},
* {{merged to}}, {{merged from}}, etc.
*
* @return An array of AttributionNotice objects.
*/
findNotices() {
this.buildIndex();
// Used instead of `this.notices.values()` to exclude nodes that are no longer on the DOM.
const notices = [];
for (const node of this.findTemplate(WikiAttributionNotices.templateAliasRegExp, true)) {
if (!this.notices.has(node.element)) {
// Notice not yet cached, but this is an attribution notice.
// Now to determine what type.
const type = WikiAttributionNotices.getTemplateNoticeType(node.getTarget().href);
const noticeInstance = new (WikiAttributionNotices.attributionNoticeClasses[type])(CTEParsoidTransclusionTemplateNode.upgradeNode(node, this));
this.notices.set(node.element, noticeInstance);
}
notices.push(this.notices.get(node.element));
}
return notices;
}
/**
* Find all notices which have rows using their 'href' fields.
*
* @return All found {@link RowedAttributionNotice}s
*/
findRowedNoticesByHref() {
return organize(this.findNotices().filter(v => v instanceof RowedAttributionNotice), (v) => v.node.getTarget().href);
}
/**
* Finds this document's {{copied}} notices.
*
* @param type
* @return An array of all CopiedTemplate objects found
*/
findNoticeType(type) {
return this.findNotices().filter((notice) => notice instanceof
WikiAttributionNotices.attributionNoticeClasses[type]);
}
/**
* Look for a good spot to place a {{copied}} template.
*
* @param type The type of the notice to look a spot for.
* @return A spot to place the template, `null` if a spot could not be found.
*/
findNoticeSpot(type) {
var _a, _b;
// TODO: Just use a simple "if" for {{translated page}}.
const positionIndices = {
copied: 0,
splitArticle: 1,
mergedFrom: 2,
mergedTo: 3,
backwardsCopy: 4,
translatedPage: 5
};
const positionIndex = positionIndices[type];
const variableSpots = [
[
positionIndex >= positionIndices.copied ? 'afterend' : 'beforebegin',
positionIndex >= positionIndices.copied ?
last(this.document.querySelectorAll('.copiednotice')) :
this.document.querySelector('.copiednotice')
],
[
positionIndex >= positionIndices.splitArticle ? 'afterend' : 'beforebegin',
positionIndex >= positionIndices.splitArticle ?
last(this.document.querySelectorAll('.box-split-article')) :
this.document.querySelector('.box-split-article')
],
[
positionIndex >= positionIndices.mergedFrom ? 'afterend' : 'beforebegin',
positionIndex >= positionIndices.mergedFrom ?
last(this.document.querySelectorAll('.box-merged-from')) :
this.document.querySelector('.box-merged-from')
],
[
positionIndex >= positionIndices.mergedTo ? 'afterend' : 'beforebegin',
positionIndex >= positionIndices.mergedTo ?
last(this.document.querySelectorAll('.box-merged-to')) :
this.document.querySelector('.box-merged-to')
],
[
positionIndex >= positionIndices.backwardsCopy ? 'afterend' : 'beforebegin',
positionIndex >= positionIndices.backwardsCopy ?
last(this.document.querySelectorAll('.box-backwards-copy')) :
this.document.querySelector('.box-backwards-copy')
],
[
positionIndex >= positionIndices.translatedPage ? 'afterend' : 'beforebegin',
positionIndex >= positionIndices.translatedPage ?
last(this.document.querySelectorAll('.box-translated-page')) :
this.document.querySelector('.box-translated-page')
]
];
// Move everything after the template type we're looking for to the start of the array.
// Also place the exact type we're looking for at the top of the array.
// This prioritizes the highest (by position) template in the page.
const afterSpots = variableSpots.splice(positionIndex + 1, variableSpots.length - positionIndex + 1);
const beforeSpots = variableSpots.splice(0, positionIndex).reverse();
moveToStart(variableSpots, 0);
variableSpots.push(...beforeSpots, ...afterSpots);
const possibleSpots = [
...variableSpots,
// After the {{to do}} template
['afterend', last(this.document.querySelectorAll('.t-todo'))],
// After the WikiProject banner shell
['afterend', this.document.querySelector('.wpbs') ? last(this.document.querySelectorAll(`[about="${this.document.querySelector('.wpbs')
.getAttribute('about')}"]`)) : null],
// After all WikiProject banners
['afterend', last(this.document.querySelectorAll('.wpb'))],
// After the last talk page message box that is not a small box
['afterend', last(this.document.querySelectorAll('[data-mw-section-id="0"] .tmbox:not(.mbox-small):not(.talkheader)'))],
// After the talk page header
['afterend', this.document.querySelector('.talkheader')],
// After the rcat shell
['afterend', this.document.querySelector('.box-Redirect_category_shell')],
// After rcats
['afterend', last(this.document.querySelectorAll('.rcat'))],
// After the #REDIRECT line
['afterend', (_a = this.document.querySelector('[rel="mw:PageProp/redirect"]')) === null || _a === void 0 ? void 0 : _a.parentElement],
// At the start of the talk page
['afterbegin', this.document.querySelector('section[data-mw-section-id="0"]')]
];
for (const spot of possibleSpots) {
if (spot[1] != null) {
if (spot[1].hasAttribute('data-mw') ||
(!spot[1].getAttribute('about') &&
!spot[1].getAttribute('id'))) {
return spot;
}
else {
const identifier = ((_b = spot[1].getAttribute('about')) !== null && _b !== void 0 ? _b : spot[1].getAttribute('id')).replace(/^#/, '');
// Find the last element from that specific transclusion.
const transclusionRoot = last(this.document.querySelectorAll(`#${identifier}, [about="#${identifier}"]`));
return [
spot[0],
transclusionRoot
];
}
}
}
return null;
}
/**
* Inserts a new attribution notice of a given type.
*
* @param type A notice type
* @param spot The spot to place the template.
* @param spot."0" See {@link CTEParsoidDocument.findNoticeSpot()}[0]
* @param spot."1" See {@link CTEParsoidDocument.findNoticeSpot()}[1]
*/
insertNewNotice(type, [position, element]) {
const template = {
copied: TemplateFactory.copied,
splitArticle: TemplateFactory.splitArticle,
mergedFrom: TemplateFactory.mergedFrom,
mergedTo: TemplateFactory.mergedTo,
backwardsCopy: TemplateFactory.backwardsCopy,
translatedPage: TemplateFactory.translatedPage
}[type](this);
// Insert.
element.insertAdjacentElement(position, template.element);
this.notices.set(template.element, template);
this.dispatchEvent(new TemplateInsertEvent(template));
}
}
CTEParsoidDocument.addedRows = 1;
/**
* Extremely minimalist valid Parsoid document. This includes a section 0
* element for findCopiedNoticeSpot.
*
* @type {string}
*/
CTEParsoidDocument.defaultDocument = '<html><body><section data-mw-section-id="0"></section></body></html>';
/**
* Converts a normal error into an OO.ui.Error for ProcessDialogs.
*
* @param error A plain error object.
* @param config Error configuration.
* @param config.recoverable Whether or not the error is recoverable.
* @param config.warning Whether or not the error is a warning.
* @return An OOUI Error.
*/
function errorToOO(error, config) {
return new OO.ui.Error(error.message, config);
}
const exitBlockList = [];
/**
* Used to block an impending exit.
*
* @param event The unload event
* @return `false`.
*/
const exitBlock = (event) => {
if (exitBlockList.length > 0) {
event.preventDefault();
return event.returnValue = false;
}
};
window.addEventListener('beforeunload', exitBlock);
/**
* Blocks navigation to prevent data loss. This function takes in a
* `key` parameter to identify which parts of the tool are blocking navigation.
* The exit block will refuse to unlatch from the document if all keys are not
* released with `unblockExit`.
*
* If no key is provided, this will unconditionally set the block. Running
* any operation that updates the block list (e.g. `unblockExit` with a key
* not blocked) will immediately unblock the page.
*
* @param key The key of the exit block.
*/
function blockExit(key) {
if (key) {
if (exitBlockList.indexOf(key) === -1) {
exitBlockList.push(key);
}
}
}
/**
* Unblocks navigation. This function takes in a `key` parameter to identify
* which part of the tool is no longer requiring a block. If other parts of
* the tool still require blocking, the unblock function will remain on the
* document.
*
* If no key is provided, this will dump all keys and immediate unblock exit.
*
* @param key The key of the exit block.
*/
function unblockExit(key) {
if (key) {
const keyIndex = exitBlockList.indexOf(key);
if (keyIndex !== -1) {
exitBlockList.splice(keyIndex, 1);
}
}
else {
exitBlockList.splice(0, exitBlockList.length);
}
}
var blockExit$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
blockExit: blockExit,
unblockExit: unblockExit
});
let InternalCopiedTemplateEditorDialog;
/**
* Initializes the process element.
*/
function initCopiedTemplateEditorDialog() {
var _a;
InternalCopiedTemplateEditorDialog = (_a = class CopiedTemplateEditorDialog extends OO.ui.ProcessDialog {
/**
* @param config
*/
constructor(config) {
super(config);
/**
* Parsoid document for this dialog.
*/
this.parsoid = new CTEParsoidDocument();
/**
* A map of OOUI PageLayouts keyed by their notices. These PageLayouts also include
* functions specific to AttributionNoticePageLayout, such as functions to get child
* pages.
*/
this.pageCache = new Map();
this.main = config.main;
}
/**
* @return The body height of this dialog.
*/
getBodyHeight() {
return 900;
}
/**
* Initializes the dialog.
*/
initialize() {
super.initialize();
this.layout = new OO.ui.BookletLayout({
continuous: true,
outlined: true
});
this.layout.on('remove', () => {
if (Object.keys(this.layout.pages).length === 0) {
// If no pages left, append the "no notices" page.
this.layout.addPages([CopiedTemplatesEmptyPage({
parent: this,
parsoid: this.parsoid
})], 0);
}
});
this.parsoid.addEventListener('templateInsert', (event) => {
if (!this.pageCache.has(event.template)) {
this.pageCache.set(event.template, event.template.generatePage(this));
this.rebuildPages();
}
});
this.renderMenuActions();
this.$body.append(this.layout.$element);
return this;
}
/**
* Rebuilds the pages of this dialog.
*/
rebuildPages() {
const notices = this.parsoid.findNotices();
const pages = [];
for (const notice of notices) {
let cachedPage = this.pageCache.get(notice);
if (cachedPage == null) {
cachedPage = notice.generatePage(this);
this.pageCache.set(notice, cachedPage);
}
pages.push(cachedPage);
if (cachedPage.getChildren != null) {
pages.push(...cachedPage.getChildren());
}
}
const lastFocusedPage = this.layout.getCurrentPage();
let nextFocusedPageName;
const layoutPages = getObjectValues(this.layout.pages);
const removed = layoutPages
.filter((item) => pages.indexOf(item) === -1);
if (removed.indexOf(lastFocusedPage) === -1) {
// Focus on an existing (and currently focused) page.
nextFocusedPageName = this.layout.getCurrentPageName();
}
else if (lastFocusedPage != null && pages.length > 0) {
const layoutNames = Object.keys(this.layout.pages);
// Find the next page AFTER the one previously focused on (which is
// about to get removed).
for (let i = layoutNames.indexOf(lastFocusedPage.getName()); i < layoutNames.length; i++) {
const layoutName = layoutNames[i];
if (removed.some((p) => p.getName() !== layoutName)) {
// This element will not get removed later on. Use it.
nextFocusedPageName = layoutName;
break;
}
}
if (nextFocusedPageName == null) {
// Fall back to last element in the set (most likely position)
nextFocusedPageName = last(pages).getName();
}
}
// Remove all removed pages
this.layout.removePages(removed);
// Jank, but no other options while page rearranging isn't a thing.
this.layout.addPages(pages);
if (nextFocusedPageName) {
this.layout.setPage(nextFocusedPageName);
}
// Delete deleted pages from cache.
this.pageCache.forEach((page, notice) => {
if (removed.indexOf(page) !== -1) {
this.pageCache.delete(notice);
}
});
}
/**
* Renders the collection of actions at the top of the page menu. Also
* appends the panel to the layout.
*/
renderMenuActions() {
const addButton = new OO.ui.ButtonWidget({
icon: 'add',
framed: false,
invisibleLabel: true,
label: mw.msg('deputy.ante.add'),
title: mw.msg('deputy.ante.add'),
flags: ['progressive']
});
this.mergeButton = new OO.ui.ButtonWidget({
icon: 'tableMergeCells',
framed: false,
invisibleLabel: true,
label: mw.msg('deputy.ante.mergeAll'),
title: mw.msg('deputy.ante.mergeAll')
});
this.mergeButton.on('click', () => {
const notices = this.parsoid.findRowedNoticesByHref();
const noticeCount = Object.values(notices)
.filter(v => v.length > 1)
.reduce((p, n) => p + n.length, 0);
return noticeCount ?
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.mergeAll.confirm', `${noticeCount}`).text()).done((confirmed) => {
if (!confirmed) {
return;
}
for (const noticeSet of Object.values(notices)) {
TemplateMerger.merge(noticeSet);
}
}) :
OO.ui.alert('There are no templates to merge.');
});
const resetButton = new OO.ui.ButtonWidget({
icon: 'reload',
framed: false,
invisibleLabel: true,
label: mw.msg('deputy.ante.reset'),
title: mw.msg('deputy.ante.reset')
});
resetButton.on('click', () => {
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.msg('deputy.ante.reset.confirm')).done((confirmed) => {
if (confirmed) {
this.loadTalkPage().then(() => {
this.layout.clearPages();
this.rebuildPages();
});
}
});
});
const deleteButton = new OO.ui.ButtonWidget({
icon: 'trash',
framed: false,
invisibleLabel: true,
label: mw.msg('deputy.ante.delete'),
title: mw.msg('deputy.ante.delete'),
flags: ['destructive']
});
deleteButton.on('click', () => {
// Original copied notice count.
const notices = this.parsoid.findNotices();
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.delete.confirm', `${notices.length}`).text()).done((confirmed) => {
if (confirmed) {
for (const notice of notices) {
notice.destroy();
}
}
});
});
const previewButton = new OO.ui.ButtonWidget({
icon: 'eye',
framed: false,
invisibleLabel: true,
label: mw.msg('deputy.ante.preview'),
title: mw.msg('deputy.ante.preview'),
flags: ['destructive']
});
previewButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
previewButton.setDisabled(true);
yield openWindow(DeputyReviewDialog({
title: normalizeTitle(this.parsoid.getPage()),
from: yield getPageContent(this.parsoid.getPage()),
to: yield this.parsoid.toWikitext()
}));
previewButton.setDisabled(false);
}));
this.layout.on('remove', () => {
this.mergeButton.setDisabled(!Object.values(this.parsoid.findRowedNoticesByHref())
.some(v => v.length > 1));
deleteButton.setDisabled(this.parsoid.findNotices().length === 0);
});
this.parsoid.addEventListener('templateInsert', () => {
this.mergeButton.setDisabled(!Object.values(this.parsoid.findRowedNoticesByHref())
.some(v => v.length > 1));
deleteButton.setDisabled(this.parsoid.findNotices().length === 0);
});
this.$overlay.append(new AttributionNoticeAddMenu(this.parsoid, addButton).render());
const actionPanel = h_1("div", { class: "cte-actionPanel" },
unwrapWidget(addButton),
unwrapWidget(this.mergeButton),
unwrapWidget(resetButton),
unwrapWidget(deleteButton),
unwrapWidget(previewButton));
const targetPanel = unwrapWidget(this.layout).querySelector('.oo-ui-menuLayout .oo-ui-menuLayout-menu');
targetPanel.insertAdjacentElement('afterbegin', actionPanel);
}
/**
* Loads the talk page.
*/
loadTalkPage() {
return __awaiter(this, void 0, void 0, function* () {
const talkPage = new mw.Title(mw.config.get('wgPageName'))
.getTalkPage()
.getPrefixedText();
// Load the talk page
yield this.parsoid.loadPage(talkPage, { reload: true })
.catch(errorToOO)
.then(() => true);
if (this.parsoid.getPage() !== talkPage) {
// Ask for user confirmation.
dangerModeConfirm(window.CopiedTemplateEditor.config, mw.message('deputy.ante.loadRedirect.message', talkPage, this.parsoid.getPage()).text(), {
title: mw.msg('deputy.ante.loadRedirect.title'),
actions: [
{
action: 'accept',
label: mw.msg('deputy.ante.loadRedirect.source')
},
{
action: 'deny',
label: mw.msg('deputy.ante.loadRedirect.target')
}
]
}).then((loadSource) => {
if (loadSource) {
// Load redirect page.
return this.parsoid.loadPage(talkPage, { followRedirects: false, reload: true }).catch(errorToOO).then(() => true);
}
});
}
});
}
/**
* Gets the setup process for this dialog. This is run prior to the dialog
* opening.
*
* @param data Additional data. Unused for this dialog.
* @return An OOUI Process
*/
getSetupProcess(data) {
const process = super.getSetupProcess(data);
// Load the talk page
if (this.parsoid.getDocument() == null) {
process.next(this.loadTalkPage());
}
// Rebuild the list of pages
process.next(() => {
this.rebuildPages();
return true;
});
// Block exits
process.next(() => {
blockExit('cte');
return true;
});
return process;
}
/**
* Gets this dialog's ready process. Called after the dialog has opened.
*
* @return An OOUI Process
*/
getReadyProcess() {
const process = super.getReadyProcess();
// Recheck state of merge button
this.mergeButton.setDisabled(!Object.values(this.parsoid.findRowedNoticesByHref())
.some(v => v.length > 1));
process.next(() => {
for (const page of getObjectValues(this.layout.pages)) {
// Dirty check to see if this is a CopiedTemplatePage.
if (page.updatePreview != null) {
page.updatePreview();
}
}
}, this);
return process;
}
/**
* Gets this dialog's action process. Handles all actions (primarily dialog
* button clicks, etc.)
*
* @param action
* @return An OOUI Process
*/
getActionProcess(action) {
const process = super.getActionProcess(action);
if (action === 'save') {
// Quick and dirty validity check.
if (unwrapWidget(this.layout)
.querySelector('.oo-ui-flaggedElement-invalid') != null) {
return new OO.ui.Process(() => {
OO.ui.alert(mw.msg('deputy.ante.invalid'));
});
}
// Saves the page.
process.next(() => __awaiter(this, void 0, void 0, function* () {
return new mw.Api().postWithEditToken(Object.assign(Object.assign({}, changeTag(yield window.CopiedTemplateEditor.getWikiConfig())), { action: 'edit', format: 'json', formatversion: '2', utf8: 'true', title: this.parsoid.getPage(), text: yield this.parsoid.toWikitext(), summary: decorateEditSummary(mw.msg(this.parsoid.originalCount > 0 ?
'deputy.ante.content.modify' :
'deputy.ante.content.add'), window.CopiedTemplateEditor.config) })).catch((e, c) => {
throw errorToOO(e, c);
});
}), this);
// Page redirect
process.next(() => {
unblockExit('cte');
if (mw.config.get('wgPageName') === this.parsoid.getPage()) {
// If on the talk page, reload the page.
window.location.reload();
}
else {
// If on another page, open the talk page.
window.location.href =
mw.config.get('wgArticlePath').replace(/\$1/g, encodeURIComponent(this.parsoid.getPage()));
}
}, this);
}
process.next(() => {
this.close({ action: action });
}, this);
return process;
}
/**
* Gets the teardown process. Called when the dialog is closing.
*
* @return An OOUI process.
*/
getTeardownProcess() {
const process = super.getTeardownProcess();
process.next(() => {
// Already unblocked if "save", but this cuts down on code footprint.
unblockExit('cte');
this.main.toggleButtons(true);
});
return process;
}
},
_a.static = Object.assign(Object.assign({}, OO.ui.ProcessDialog.static), { name: 'copiedTemplateEditorDialog', title: mw.msg('deputy.ante'), size: 'huge', actions: [
{
flags: ['primary', 'progressive'],
label: mw.msg('deputy.save'),
title: mw.msg('deputy.save'),
action: 'save'
},
{
flags: ['safe', 'close'],
icon: 'close',
label: mw.msg('deputy.close'),
title: mw.msg('deputy.close'),
invisibleLabel: true,
action: 'close'
}
] }),
_a);
}
/**
* Creates a new CopiedTemplateEditorDialog.
*
* @param config
* @return A CopiedTemplateEditorDialog object
*/
function CopiedTemplateEditorDialog (config) {
if (!InternalCopiedTemplateEditorDialog) {
initCopiedTemplateEditorDialog();
}
return new InternalCopiedTemplateEditorDialog(config);
}
var deputyAnteEnglish = {
"deputy.ante.content.modify": "Modifying content attribution notices.",
"deputy.ante.content.add": "Adding content attribution notices.",
"deputy.ante.edit": "Modify content attribution notices for this page",
"deputy.ante.add": "Add a notice",
"deputy.ante.mergeAll": "Merge all notices",
"deputy.ante.mergeAll.confirm": "You are about to merge $1 {{PLURAL:$1|notice|notices}} which support rows. Continue?",
"deputy.ante.reset": "Reset all changes",
"deputy.ante.reset.confirm": "This will reset all changes. Proceed?",
"deputy.ante.delete": "Delete all notices",
"deputy.ante.delete.confirm": "You are about to delete $1 {{PLURAL:$1|notice|notices}}. Continue?",
"deputy.ante.preview": "Preview changes",
"deputy.ante.loadRedirect.title": "Redirected talk page",
"deputy.ante.loadRedirect.message": "The talk page for \"$1\" currently redirects to \"$2\". Which page should be used for modifying attribution notices?",
"deputy.ante.loadRedirect.source": "Source (redirect) page",
"deputy.ante.loadRedirect.target": "Target page",
"deputy.ante.nocat.head": "<code>nocat</code> is enabled",
"deputy.ante.nocat.help": "This notice has the <code>nocat</code> parameter enabled and, as an effect, is not being tracked in categories. This usually means that the template is for demonstration purposes only.",
"deputy.ante.nocat.clear": "Restore tracking",
"deputy.ante.demo.head": "<code>demo</code> is enabled",
"deputy.ante.demo.help": "This notice has the <code>demo</code> parameter enabled. This usually means that the template is for demonstration purposes only.",
"deputy.ante.demo.clear": "Clear demo mode",
"deputy.ante.invalid": "Some fields are still invalid.",
"deputy.ante.adding": "Adding content attribution notices",
"deputy.ante.modifying": "Modifying content attribution notices",
"deputy.ante.dirty": "This dialog did not close properly last time. Your changes will be reset.",
"deputy.ante.empty.header": "No notices",
"deputy.ante.empty.removed": "All notices will be removed from the page. To reset your changes and restore previous templates, press the reset button at the bottom of the dialog.",
"deputy.ante.empty.none": "There are currently no notices on the talk page.",
"deputy.ante.empty.add": "Add a notice",
"deputy.ante.noSpot": "Sorry, but a notice cannot be automatically added. Please contact the developer to possibly add support for this talk page.",
"deputy.ante.merge": "Merge",
"deputy.ante.merge.title": "Merge notices",
"deputy.ante.merge.from.label": "Notices to merge",
"deputy.ante.merge.from.select": "Select a notice",
"deputy.ante.merge.from.empty": "No notices to merge",
"deputy.ante.merge.all": "Merge all",
"deputy.ante.merge.all.confirm": "You are about to merge $1 'copied' {{PLURAL:$1|notice|notices}} into this notice. Continue?",
"deputy.ante.merge.button": "Merge",
"deputy.ante.templateOptions": "Template options",
"deputy.ante.dateAuto": "Pull the date from the provided revision ID (`$1` parameter)",
"deputy.ante.dateAuto.invalid": "Parameter does not appear to be a valid revision ID.",
"deputy.ante.dateAuto.failed": "Could not pull date from revision: $1",
"deputy.ante.dateAuto.missing": "The revision $1 could not be found. Its page may have been deleted.",
"deputy.ante.revisionAuto": "Latest",
"deputy.ante.revisionAuto.title": "Pull the revision ID from the latest (current) revision of the page in `$1`.",
"deputy.ante.revisionAuto.failed": "Could not pull revision ID from page: $1",
"deputy.ante.revisionAuto.missing": "The page $1 could not be found. It may have been deleted.",
"deputy.ante.copied.label": "Copied $1",
"deputy.ante.copied.remove": "Remove notice",
"deputy.ante.copied.remove.confirm": "This will destroy $1 {{PLURAL:$1|entry|entries}}. Continue?",
"deputy.ante.copied.add": "Add entry",
"deputy.ante.copied.entry.label": "Template entry",
"deputy.ante.copied.entry.short": "$1 to $2",
"deputy.ante.copied.entry.shortTo": "To $1",
"deputy.ante.copied.entry.shortFrom": "From $1",
"deputy.ante.copied.entry.remove": "Remove entry",
"deputy.ante.copied.entry.copy": "Copy attribution edit summary",
"deputy.ante.copied.entry.copy.lacking": "Attribution edit summary copied to clipboard with lacking properties. Ensure that `from` is supplied.",
"deputy.ante.copied.entry.copy.success": "Attribution edit summary copied to clipboard.",
"deputy.ante.copied.collapse": "Collapse",
"deputy.ante.copied.small": "Small",
"deputy.ante.copied.convert": "Convert",
"deputy.ante.copied.from.placeholder": "Page A",
"deputy.ante.copied.from.label": "Page copied from",
"deputy.ante.copied.from.help": "This is the page from which the content was copied from.",
"deputy.ante.copied.from_oldid.placeholder": "from_oldid",
"deputy.ante.copied.from_oldid.label": "Revision ID",
"deputy.ante.copied.from_oldid.help": "The specific revision ID at the time that the content was copied, if known.",
"deputy.ante.copied.to.placeholder": "Page B",
"deputy.ante.copied.to.label": "Page copied to",
"deputy.ante.copied.to.help": "This is the page where the content was copied into.",
"deputy.ante.copied.to_diff.placeholder": "to_diff",
"deputy.ante.copied.to_diff.label": "Revision ID",
"deputy.ante.copied.to_diff.help": "The specific revision ID of the revision that copied content into the target page. If the copying spans multiple revisions, this is the ID of the last revision that copies content into the page.",
"deputy.ante.copied.to_oldid.placeholder": "to_oldid",
"deputy.ante.copied.to_oldid.label": "Starting revision ID",
"deputy.ante.copied.to_oldid.help": "The ID of the revision before any content was copied. This can be omitted unless multiple revisions copied content into the page.",
"deputy.ante.copied.diff.placeholder": "https://en.wikipedia.org/w/index.php?diff=123456",
"deputy.ante.copied.diff.label": "Diff URL",
"deputy.ante.copied.diff.help": "The URL of the diff. Using <code>to_diff</code> and <code>to_oldid</code> is preferred, although supplying this parameter will override both.",
"deputy.ante.copied.merge.label": "Merged?",
"deputy.ante.copied.merge.help": "Whether the copying was done as a result of merging two pages.",
"deputy.ante.copied.afd.placeholder": "AfD page (without Wikipedia:Articles for deletion/)",
"deputy.ante.copied.afd.label": "AfD page",
"deputy.ante.copied.afd.help": "The AfD page if the copy was made due to an AfD closed as \"merge\".",
"deputy.ante.copied.date.placeholder": "Date (YYYY-MM-DD)",
"deputy.ante.copied.advanced": "Advanced",
"deputy.ante.copied.dateInvalid": "The previous date value, \"$1\", was not a valid date.",
"deputy.ante.copied.diffDeprecate": "The <code>to_diff</code> and <code>to_oldid</code> parameters are preferred in favor of the <code>diff</code> parameter.",
"deputy.ante.copied.diffDeprecate.warnHost": "The URL in this parameter is not the same as the wiki you're currently editing on. Continue?",
"deputy.ante.copied.diffDeprecate.replace": "The current value of '$1', \"$2\", will be replaced with \"$3\". Continue?",
"deputy.ante.copied.diffDeprecate.failed": "Cannot convert `diff` parameter to URL. See your browser console for more details.",
"deputy.ante.splitArticle.label": "Split article $1",
"deputy.ante.splitArticle.remove": "Remove notice",
"deputy.ante.splitArticle.remove.confirm": "This will destroy $1 {{PLURAL:$1|entry|entries}}. Continue?",
"deputy.ante.splitArticle.add": "Add entry",
"deputy.ante.splitArticle.entry.label": "Template entry",
"deputy.ante.splitArticle.entry.remove": "Remove entry",
"deputy.ante.splitArticle.entry.short": "$1 on $2",
"deputy.ante.splitArticle.collapse": "Collapse",
"deputy.ante.splitArticle.from": "From",
"deputy.ante.splitArticle.from.help": "This is the page where the content was split from. In most cases, this is the current page, and can be left blank.",
"deputy.ante.splitArticle.to.placeholder": "Subpage A",
"deputy.ante.splitArticle.to.label": "Page split to",
"deputy.ante.splitArticle.to.help": "This is the name of page that material was copied to; the \"merge target\".",
"deputy.ante.splitArticle.from_oldid.placeholder": "from_oldid",
"deputy.ante.splitArticle.from_oldid.label": "As of revision ID",
"deputy.ante.splitArticle.from_oldid.help": "The revision ID of the original page prior to the split. This is the revision that still contains content that will eventually become part of the split, with the following revision (or succeeding revisions) progressively transferring content to the other pages.",
"deputy.ante.splitArticle.date.label": "Date of split",
"deputy.ante.splitArticle.date.help": "The date that the split occurred.",
"deputy.ante.splitArticle.diff.placeholder": "123456789",
"deputy.ante.splitArticle.diff.label": "Diff",
"deputy.ante.splitArticle.diff.help": "The diff URL or revision ID of the split.",
"deputy.ante.mergedFrom.label": "Merged from $1",
"deputy.ante.mergedFrom.remove": "Remove notice",
"deputy.ante.mergedFrom.article.placeholder": "Page A",
"deputy.ante.mergedFrom.article.label": "Original article",
"deputy.ante.mergedFrom.article.help": "This is the page where the merged content is or used to be.",
"deputy.ante.mergedFrom.date.label": "Date",
"deputy.ante.mergedFrom.date.help": "The date (in UTC) when the content was merged into this page.",
"deputy.ante.mergedFrom.talk.label": "Link to talk?",
"deputy.ante.mergedFrom.talk.help": "Whether to link to the original article's talk page or not.",
"deputy.ante.mergedFrom.target.placeholder": "Page B",
"deputy.ante.mergedFrom.target.label": "Merge target",
"deputy.ante.mergedFrom.target.help": "The page that the content was merged into. Used if the page that the content was merged into was the talk page.",
"deputy.ante.mergedFrom.afd.placeholder": "Wikipedia:Articles for deletion/Page A",
"deputy.ante.mergedFrom.afd.label": "AfD",
"deputy.ante.mergedFrom.afd.help": "The AfD discussion that led to the merge. If this merge was not the result of an AfD discussion, leave this blank.",
"deputy.ante.mergedTo.label": "Merged to $1",
"deputy.ante.mergedTo.remove": "Remove notice",
"deputy.ante.mergedTo.to.placeholder": "Page A",
"deputy.ante.mergedTo.to.label": "Target article",
"deputy.ante.mergedTo.to.help": "This is the page where content was copied into.",
"deputy.ante.mergedTo.date.label": "Date",
"deputy.ante.mergedTo.date.help": "The date (in UTC) when the content was merged into this page.",
"deputy.ante.mergedTo.small.label": "Small",
"deputy.ante.mergedTo.small.help": "If enabled, makes the banner small.",
"deputy.ante.backwardsCopy.label": "Backwards copy $1",
"deputy.ante.backwardsCopy.remove": "Remove notice",
"deputy.ante.backwardsCopy.bot": "This notice was automatically added in by [[User:$1|$1]] ([[User talk:$1|talk]]). Changing this template will remove this warning as it is assumed that you have properly vetted the bot-added parameters.",
"deputy.ante.backwardsCopy.entry.label": "Template entry",
"deputy.ante.backwardsCopy.entry.short": "Copied in '$1'",
"deputy.ante.backwardsCopy.entry.remove": "Remove entry",
"deputy.ante.backwardsCopy.comments.placeholder": "Additional information",
"deputy.ante.backwardsCopy.comments.label": "Comments",
"deputy.ante.backwardsCopy.comments.help": "Additional comments related to the backwards copies.",
"deputy.ante.backwardsCopy.id.placeholder": "123456789",
"deputy.ante.backwardsCopy.id.label": "Revision ID",
"deputy.ante.backwardsCopy.id.help": "The last revision ID of this article that does not contain content that was duplicated by copying media.",
"deputy.ante.backwardsCopy.entry.title.placeholder": "Article, journal, or medium name",
"deputy.ante.backwardsCopy.entry.title.label": "Publication name",
"deputy.ante.backwardsCopy.entry.title.help": "The publication title. This is the title of the medium that copied from Wikipedia",
"deputy.ante.backwardsCopy.entry.date.placeholder": "12 Feburary 2022",
"deputy.ante.backwardsCopy.entry.date.label": "Publishing date",
"deputy.ante.backwardsCopy.entry.date.help": "This is the date on which the article was first published.",
"deputy.ante.backwardsCopy.entry.author.placeholder": "Add author",
"deputy.ante.backwardsCopy.entry.author.label": "Author",
"deputy.ante.backwardsCopy.entry.author.help": "The article's author.",
"deputy.ante.backwardsCopy.entry.url.placeholder": "https://example.com/news/a-news-article-that-copies-from-wikipedia",
"deputy.ante.backwardsCopy.entry.url.label": "URL",
"deputy.ante.backwardsCopy.entry.url.help": "A URL to the published media, if it exists as an online resource. If this is not an online resource (newspaper media, other printed media), leave this blank.",
"deputy.ante.backwardsCopy.entry.org.placeholder": "Example Publishing",
"deputy.ante.backwardsCopy.entry.org.label": "Publisher",
"deputy.ante.backwardsCopy.entry.org.help": "The publisher of the media. This may be a news company or a book publishing company.",
"deputy.ante.translatedPage.label": "Translated from $1:$2",
"deputy.ante.translatedPage.remove": "Remove notice",
"deputy.ante.translatedPage.lang.placeholder": "en, de, fr, es, etc.",
"deputy.ante.translatedPage.lang.label": "Language code",
"deputy.ante.translatedPage.lang.help": "The language code of the wiki that the page was translated from. This is the \"en\" of the English Wikipedia, or the \"fr\" of the French Wikipedia.",
"deputy.ante.translatedPage.page.placeholder": "Page on other wiki",
"deputy.ante.translatedPage.page.label": "Source page",
"deputy.ante.translatedPage.page.help": "The page on the other wiki that the content was copied from. Do not translate the page title.",
"deputy.ante.translatedPage.comments.placeholder": "Additional comments",
"deputy.ante.translatedPage.comments.label": "Comments",
"deputy.ante.translatedPage.comments.help": "Additional comments that are pertinent to translation.",
"deputy.ante.translatedPage.version.placeholder": "123456789",
"deputy.ante.translatedPage.version.label": "Source revision ID",
"deputy.ante.translatedPage.version.help": "The revision ID of the source page at the time of translation.",
"deputy.ante.translatedPage.insertversion.placeholder": "987654321",
"deputy.ante.translatedPage.insertversion.label": "Insertion revision ID",
"deputy.ante.translatedPage.insertversion.help": "The revision ID of the revision where the translated content was inserted into the page bearing this notice.",
"deputy.ante.translatedPage.section.placeholder": "Section name (leave blank if N/A)",
"deputy.ante.translatedPage.section.label": "Section",
"deputy.ante.translatedPage.section.help": "The section of the page that was translated, if a specific section was translated. Leave blank if this does not apply, or if translation was performed on the entire page or more than one section.",
"deputy.ante.translatedPage.small.label": "Small?",
"deputy.ante.translatedPage.small.help": "Whether to render the template as a small message box or not. By default, a small box is used. If you have a good reason to use a full-sized banner, disable this option.",
"deputy.ante.translatedPage.partial.label": "Partial?",
"deputy.ante.translatedPage.partial.help": "Whether this translation is a partial translation or not.",
"deputy.ante.translatedPage.copy": "Copy attribution edit summary",
"deputy.ante.translatedPage.copy.lacking": "Attribution edit summary copied to clipboard with lacking properties. Ensure that `from` is supplied.",
"deputy.ante.translatedPage.copy.success": "Attribution edit summary copied to clipboard."
};
var deputySharedEnglish = {
"deputy.name": "Deputy",
"deputy.description": "Copyright cleanup and case processing tool for Wikipedia.",
"deputy.ia": "Infringement Assistant",
"deputy.ia.short": "I. Assistant",
"deputy.ia.acronym": "Deputy: IA",
"deputy.ante": "Attribution Notice Template Editor",
"deputy.ante.short": "Attrib. Template Editor",
"deputy.ante.acronym": "Deputy: ANTE",
"deputy.cancel": "Cancel",
"deputy.review": "Review",
"deputy.review.title": "Review a diff of the changes to be made to the page",
"deputy.save": "Save",
"deputy.close": "Close",
"deputy.positiveDiff": "+{{FORMATNUM:$1}}",
"deputy.negativeDiff": "-{{FORMATNUM:$1}}",
"deputy.zeroDiff": "0",
"deputy.brokenDiff": "?",
"deputy.brokenDiff.explain": "The internal parent revision ID for this diff points to a non-existent revision. [[phab:T186280]] has more information.",
"deputy.moreInfo": "More information",
"deputy.dismiss": "Dismiss",
"deputy.revision.cur": "cur",
"deputy.revision.prev": "prev",
"deputy.revision.cur.tooltip": "Difference with latest revision",
"deputy.revision.prev.tooltip": "Difference with preceding revision",
"deputy.revision.talk": "talk",
"deputy.revision.contribs": "contribs",
"deputy.revision.bytes": "{{FORMATNUM:$1}} bytes",
"deputy.revision.byteChange": "{{FORMATNUM:$1}} bytes after change of this size",
"deputy.revision.tags": "{{PLURAL:$1|Tag|Tags}}:",
"deputy.revision.new": "N",
"deputy.revision.new.tooltip": "This edit created a new page.",
"deputy.comma-separator": ", ",
"deputy.diff": "Review your changes",
"deputy.diff.load": "Loading changes...",
"deputy.diff.no-changes": "No difference",
"deputy.diff.error": "An error occurred while trying to get the comparison.",
"deputy.loadError.userConfig": "Due to an error, your Deputy configuration has been reset.",
"deputy.loadError.wikiConfig": "An error occurred while loading this wiki's Deputy configuration. Please report this to the Deputy maintainers for this wiki."
};
/**
* A Deputy module. Modules are parts of Deputy that can usually be removed
* and turned into standalone components that can load without Deputy.
*/
class DeputyModule {
/**
*
* @param deputy
*/
constructor(deputy) {
this.deputy = deputy;
}
/**
* @return The responsible window manager for this class.
*/
get windowManager() {
if (!this.deputy) {
if (!this._windowManager) {
this._windowManager = new OO.ui.WindowManager();
document.body.appendChild(unwrapWidget(this._windowManager));
}
return this._windowManager;
}
else {
return this.deputy.windowManager;
}
}
/**
* @return the configuration handler for this module. If Deputy is loaded, this reuses
* the configuration handler of Deputy.
*/
get config() {
var _a;
if (!this.deputy) {
return (_a = this._config) !== null && _a !== void 0 ? _a : (this._config = UserConfiguration.load());
}
else {
return this.deputy.config;
}
}
/**
* @return the wiki-wide configuration handler for this module. If Deputy is loaded,
* this reuses the configuration handler of Deputy. Since the wiki config is loaded
* asynchronously, this may not be populated at runtime. Only use it if you're sure
* that `preInit` has already been called and finished.
*/
get wikiConfig() {
return this.deputy ? this.deputy.wikiConfig : this._wikiConfig;
}
/**
* Get the module key for this module. Allows modules to be identified with a different
* configuration key.
*
* @return The module key. the module name by default.
*/
getModuleKey() {
return this.getName();
}
/**
* Load the language pack for this module, with a fallback in case one could not be
* loaded.
*
* @param fallback The fallback to use if a language pack could not be loaded.
*/
loadLanguages(fallback) {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all([
DeputyLanguage.load(this.getName(), fallback),
DeputyLanguage.load('shared', deputySharedEnglish),
DeputyLanguage.loadMomentLocale()
]);
});
}
/**
* Pre-initialize the module. This is the opportunity of the module to load language
* strings, append important UI elements, add portlets, etc.
*
* @param languageFallback The fallback language pack to use if one could not be loaded.
*/
preInit(languageFallback) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
yield this.getWikiConfig();
if (((_a = this.wikiConfig[this.getModuleKey()]) === null || _a === void 0 ? void 0 : _a.enabled.get()) !== true) {
// Stop loading here.
warn(`Preinit for ${this.getName()} cancelled; module is disabled.`);
return false;
}
yield this.loadLanguages(languageFallback);
yield attachConfigurationDialogPortletLink();
yield this.wikiConfig.prepareEditBanners();
return true;
});
}
/**
* Gets the wiki-specific configuration for Deputy.
*
* @return A promise resolving to the loaded configuration
*/
getWikiConfig() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (this.deputy) {
return this.deputy.getWikiConfig();
}
else {
return (_a = this._wikiConfig) !== null && _a !== void 0 ? _a : (this._wikiConfig = yield WikiConfiguration.load());
}
});
}
}
var cteStyles = ".copied-template-editor .oo-ui-window-frame {width: 1000px !important;}.copied-template-editor .oo-ui-menuLayout > .oo-ui-menuLayout-menu {height: 20em;width: 20em;}.copied-template-editor .oo-ui-menuLayout > .oo-ui-menuLayout-content {left: 20em;}.cte-preview .copiednotice {margin-left: 0;margin-right: 0;}.cte-merge-panel {padding: 16px;z-index: 20;border: 1px solid lightgray;margin-bottom: 8px;}.copied-template-editor .oo-ui-bookletLayout-outlinePanel {bottom: 32px;}.cte-actionPanel {height: 32px;width: 100%;position: absolute;bottom: 0;z-index: 1;background-color: white;border-top: 1px solid #c8ccd1;}.cte-actionPanel > .oo-ui-buttonElement {display: inline-block;margin: 0 !important;}.cte-templateOptions {margin: 8px;display: flex;}.cte-templateOptions > * {flex: 1;}.cte-fieldset {border: 1px solid gray;background-color: #ddf7ff;padding: 16px;min-width: 200px;clear: both;}.cte-fieldset-date {float: left;margin-top: 10px !important;}.cte-fieldset-advswitch {float: right;}.cte-fieldset-advswitch .oo-ui-fieldLayout-field,.cte-fieldset-date .oo-ui-fieldLayout-field {display: inline-block !important;}.cte-fieldset-advswitch .oo-ui-fieldLayout-header {display: inline-block !important;margin-right: 16px;}.cte-fieldset-date .oo-ui-fieldLayout-field {min-width: 18em;}.cte-fieldset .mw-widget-dateInputWidget {max-width: unset;}.cte-page-row:not(:last-child),.cte-page-template:not(:last-child) {padding-bottom: 0 !important;}.cte-page-template + .cte-page-row {padding-top: 0 !important;}.copied-template-editor .oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-fieldsetLayout-header {position: relative;}.oo-ui-actionFieldLayout.oo-ui-fieldLayout-align-top .oo-ui-fieldLayout-header {padding-bottom: 6px !important;}.deputy.oo-ui-window {/** Place below default window manager */z-index: 199 !important;}";
/**
* Main class for CopiedTemplateEditor.
*/
class CopiedTemplateEditor extends DeputyModule {
constructor() {
super(...arguments);
this.static = CopiedTemplateEditor;
this.CopiedTemplate = CopiedTemplate;
/**
* Whether the core has been loaded or not. Set to `true` here, since this is
* obviously the core class.
*/
this.loaded = true;
/**
* Pencil icon buttons on {{copied}} templates that open CTE.
*/
this.startButtons = [];
}
/**
* @inheritDoc
*/
getName() {
return 'ante';
}
/**
* Perform actions that run *before* CTE starts (prior to execution). This involves
* adding in necessary UI elements that serve as an entry point to CTE.
*/
preInit() {
const _super = Object.create(null, {
preInit: { get: () => super.preInit }
});
return __awaiter(this, void 0, void 0, function* () {
if (!(yield _super.preInit.call(this, deputyAnteEnglish))) {
return false;
}
if (
// Button not yet appended
document.getElementById('pt-cte') == null &&
// Not virtual namespace
mw.config.get('wgNamespaceNumber') >= 0) {
mw.util.addPortletLink('p-tb', '#',
// Messages used here:
// * deputy.ante
// * deputy.ante.short
// * deputy.ante.acronym
mw.msg({
full: 'deputy.ante',
short: 'deputy.ante.short',
acronym: 'deputy.ante.acronym'
}[this.config.core.portletNames.get()]), 'pt-cte').addEventListener('click', (event) => {
event.preventDefault();
if (!event.currentTarget
.hasAttribute('disabled')) {
this.toggleButtons(false);
this.openEditDialog();
}
});
}
mw.loader.using(['oojs-ui-core', 'oojs-ui.styles.icons-editing-core'], () => {
// Only run if this script wasn't loaded using the loader.
if (!window.CopiedTemplateEditor || !window.CopiedTemplateEditor.loader) {
mw.hook('wikipage.content').add(() => {
// Find all {{copied}} templates and append our special button.
// This runs on the actual document, not the Parsoid document.
document.querySelectorAll([
'copiednotice', 'box-split-article', 'box-merged-from',
'box-merged-to', 'box-backwards-copy', 'box-translated-page'
].map((v) => `.${v} > tbody > tr`).join(', '))
.forEach((e) => {
if (e.classList.contains('cte-upgraded')) {
return;
}
e.classList.add('cte-upgraded');
const startButton = new OO.ui.ButtonWidget({
icon: 'edit',
title: mw.msg('deputy.ante.edit'),
label: mw.msg('deputy.ante.edit')
}).setInvisibleLabel(true);
this.startButtons.push(startButton);
const td = document.createElement('td');
td.style.paddingRight = '0.9em';
td.appendChild(startButton.$element[0]);
e.appendChild(td);
startButton.on('click', () => {
this.toggleButtons(false);
this.openEditDialog();
});
});
});
}
});
this.startState = true;
// Query parameter-based autostart
if (/[?&]cte-autostart(=(1|yes|true|on)?(&|$)|$)/.test(window.location.search)) {
this.toggleButtons(false);
this.openEditDialog();
}
return true;
});
}
/**
* Opens the Copied Template Editor dialog.
*/
openEditDialog() {
mw.loader.using(CopiedTemplateEditor.dependencies, () => __awaiter(this, void 0, void 0, function* () {
yield DeputyLanguage.loadMomentLocale();
OO.ui.WindowManager.static.sizes.huge = {
width: 1100
};
mw.util.addCSS(cteStyles);
yield WikiAttributionNotices.init();
if (!this.dialog) {
// The following classes are used here:
// * deputy
// * copied-template-editor
this.dialog = CopiedTemplateEditorDialog({
main: this,
classes: [
// Attach "deputy" class if Deputy.
this.deputy ? 'deputy' : null,
'copied-template-editor'
].filter((v) => !!v)
});
this.windowManager.addWindows([this.dialog]);
}
yield this.windowManager.openWindow(this.dialog).opened;
}));
}
/**
* Toggle the edit buttons.
*
* @param state The new state.
*/
toggleButtons(state) {
var _a;
this.startState = state !== null && state !== void 0 ? state : !(this.startState || false);
for (const button of this.startButtons) {
button.setDisabled(state == null ? !button.isDisabled() : !state);
}
(_a = document.getElementById('.pt-cte a')) === null || _a === void 0 ? void 0 : _a.toggleAttribute('disabled', state);
}
}
CopiedTemplateEditor.dependencies = [
'moment',
'oojs-ui-core',
'oojs-ui-windows',
'oojs-ui-widgets',
'oojs-ui.styles.icons-accessibility',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-editing-advanced',
'oojs-ui.styles.icons-interactions',
'ext.visualEditor.moduleIcons',
'mediawiki.util',
'mediawiki.api',
'mediawiki.Title',
'mediawiki.widgets',
'mediawiki.widgets.DateInputWidget',
'jquery.makeCollapsible'
];
var deputyStyles = "/*=============================================================================== GLOBAL DEPUTY CLASSES===============================================================================*/* > .deputy.dp-heading {position: absolute;opacity: 0;pointer-events: none;}*:hover > .deputy.dp-heading:not(.dp-heading--active) {opacity: 1;pointer-events: all;}.dp-loadingDots-1, .dp-loadingDots-2, .dp-loadingDots-3 {display: inline-block;margin: 0.1em 0.6em 0.1em 0.1em;width: 0.8em;height: 0.8em;background-color: rgba(0, 0, 0, 50%);animation: dp-loadingDots linear 3s infinite;border-radius: 50%;}@keyframes dp-loadingDots {0% {background-color: rgba(0, 0, 0, 10%);}16% {background-color: rgba(0, 0, 0, 40%);}32% {background-color: rgba(0, 0, 0, 10%);}100% {background-color: rgba(0, 0, 0, 10%);}}.dp-loadingDots-1 {animation-delay: -1s;}.dp-loadingDots-2 {animation-delay: -0.5s;}#mw-content-text.dp-reloading {opacity: 0.2;pointer-events: none;}p.dp-messageWidget-message {margin: 0 0 0.5em 0;}.dp-messageWidget-actions .oo-ui-buttonElement {margin-top: 0;}.oo-ui-image-destructive.oo-ui-icon-checkAll, .oo-ui-image-destructive.mw-ui-icon-checkAll::before {background-image: url(\"data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2220%22 height=%2220%22 viewBox=%220 0 20 20%22%3E%3Ctitle%3E check all %3C/title%3E%3Cpath fill=%22%23d73333%22 d=%22m.29 12.71 1.42-1.42 2.22 2.22 8.3-10.14 1.54 1.26-9.7 11.86zM12 10h5v2h-5zm-3 4h5v2H9zm6-8h5v2h-5z%22/%3E%3C/svg%3E\");}/*=============================================================================== DEPUTY REVIEW DIALOG (DeputyReviewDialog)===============================================================================*/.dp-review-progress {flex: 1;width: 60%;min-width: 300px;}/*=============================================================================== DEPUTY ENTRY POINTS (DeputyCCISessionStartLink, etc.)===============================================================================*/.deputy.dp-sessionStarter {font-size: small;font-weight: normal;margin-left: 0.25em;vertical-align: baseline;line-height: 1em;font-family: sans-serif;}.deputy.dp-sessionStarter::before {content: '\\200B';}.mw-content-ltr .deputy.dp-sessionStarter .dp-sessionStarter-bracket:first-of-type,.mw-content-rtl .deputy.dp-sessionStarter .dp-sessionStarter-bracket:not(:first-of-type) {margin-right: 0.25em;color: #54595d;}.client-js .deputy.dp-sessionStarter .dp-sessionStarter-bracket:first-of-type,.client-js .deputy.dp-sessionStarter .dp-sessionStarter-bracket:not(:first-of-type) {margin-left: 0.25em;color: #54595d}.dp-cs-section-add {position: absolute;top: 0;/* -1.6em derived from MediaWiki list margins. */left: -1.6em;width: calc(100% + 1.6em);background-color: rgba(255, 255, 255, 75%);display: flex;justify-content: center;align-items: center;}.dp-cs-section-add .dp-cs-section-addButton {opacity: 0;transition: opacity 0.2s ease-in-out;}.dp-cs-section-add:hover .dp-cs-section-addButton {opacity: 1;}/*=============================================================================== DEPUTY CONTRIBUTION SURVEY SECTION===============================================================================*/.dp-cs-section-archived .dp-cs-row-content {background-color: rgba(255, 0, 0, 6%);}.dp-cs-session-notice {margin-top: 8px;position: sticky;top: 8px;z-index: 50;}.skin-vector-2022.vector-sticky-header-visible .dp-cs-session-notice {top: calc(3.125rem + 8px);}.dp-cs-section-footer {position: relative;padding: 8px;}.dp-cs-section-danger--separator {flex-basis: 100%;margin: 8px 0;border-bottom: 1px solid #d73333;color: #d73333;font-weight: bold;font-size: 0.7em;text-align: right;text-transform: uppercase;line-height: 0.7em;padding-bottom: 0.2em;}.dp-cs-section-closing {margin: 1em 1.75em;}.dp-cs-section-progress {margin-top: 8px;max-height: 0;transition: max-height 0.2s ease-in-out;display: flex;justify-content: center;align-items: center;overflow: hidden;}.dp-cs-section-progress.active {max-height: 50px;}.dp-cs-section-progress .oo-ui-progressBarWidget {flex: 1}.dp-cs-section-closingCommentsField {margin-top: 8px;}.dp-cs-extraneous {border: 1px solid rgba(0, 159, 255, 40%);background-color: rgba(0, 159, 255, 10%);margin-bottom: 8px;padding: 16px;}.dp-cs-extraneous > dl {margin-left: -1.6em;}.dp-cs-extraneous > :first-child {margin-top: 0 !important;}.dp-cs-extraneous > :last-child {margin-bottom: 0 !important;}.dp-cs-section-archived-warn, .dp-cs-row, .dp-cs-extraneous {margin-bottom: 8px;}.dp-cs-row .dp--loadingDots {display: flex;align-items: center;justify-content: center;padding: 0.4em;}.dp-cs-row-status {max-width: 5.4em;}.dp-cs-row-status .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {width: 0;opacity: 0;}.dp-cs-row-status .dp-cs-row-status--unknown:not(.oo-ui-optionWidget-selected) {display: none;}.dp-cs-row-head > * {vertical-align: middle;}.dp-cs-row-comments {padding: 16px;background-color: rgba(0, 159, 255, 10%);margin: 4px 0;}.dp-cs-row-comments > b {letter-spacing: 0.1em;font-weight: bold;text-transform: uppercase;color: rgba(0, 0, 0, 0.5);}.dp-cs-row-comments hr {border-color: rgb(0, 31, 51);}body.mediawiki.ltr .dp-cs-row-head > :not(:first-child):not(:last-child),body.mediawiki.ltr .dp-cs-row-head > :not(:first-child):not(:last-child) {margin-right: 16px;}body.mediawiki.rtl .dp-cs-row-head > :not(:first-child):not(:last-child),body.mediawiki.rtl .dp-cs-row-head > :not(:first-child):not(:last-child) {margin-left: 16px;}.dp-cs-row-links {margin-right: 0 !important;}.dp-cs-row-links > :not(:last-child) {margin-right: 8px !important;}.dp-cs-row-title {font-weight: bold;font-size: 1.2em;vertical-align: middle;}.dp-cs-row-details {color: #4a5054;font-weight: bold;}.dp-cs-row-toggle .oo-ui-iconElement-icon {background-size: 1em;}.dp-cs-row-toggle .oo-ui-buttonElement-button {border-radius: 50%;}.dp-cs-row .history-user,.dp-cs-row :not(.newpage) + .mw-changeslist-date {margin-left: 0.4em;margin-right: 0.2em;}.dp-cs-row .newpage {margin-left: 0.4em;}.dp-cs-row-content {padding: 16px;background-color: rgba(0, 0, 0, 6%);margin: 4px 0;}.dp-cs-row-content.dp-cs-row-content-empty {display: none !important;}.dp-cs-row-unfinishedWarning {margin-bottom: 8px;}.dp-cs-section-unfinishedWarning {margin-top: 8px;}.dp-cs-row-closeComments {font-family: monospace, monospace;font-size: small;}.dp-cs-row-closeComments:not(:last-child) {margin-bottom: 8px;}.dp-cs-row-finished .oo-ui-fieldLayout:first-child {margin-top: 0;}.dp-cs-row-finished .oo-ui-fieldLayout {margin-top: 8px;}.dp-cs-row-revisions .mw-tag-markers .mw-tag-marker:not(:first-child),.dp-cs-row-detail:not(:first-child) {margin-left: 0.2em;}.dp-cs-rev-checkbox {margin-right: 4px;}.dp-cs-rev-toggleDiff {vertical-align: baseline;margin-right: 4px;}.dp-cs-rev-diff {background-color: white;position: relative;}.dp-cs-rev-diff--loaded {margin: 4px 0;padding: 8px 14px;}.dp-cs-rev-diff--hidden {display: none;}.dp-cs-rev-toggleDiff > .oo-ui-buttonElement-button {padding: 0;min-height: 1em;background-color: unset !important;}.dp-cs-rev-toggleDiff .oo-ui-indicatorElement-indicator {top: -1px;}/*=============================================================================== DEPUTY PAGE TOOLBAR===============================================================================*/.dp-pageToolbar {position: fixed;bottom: 8px;left: 8px;z-index: 100;padding: 8px;background-color: #fff;border: 1px solid gray;font-size: 0.9rem;display: flex;align-items: center;}@media only screen and (max-width: 768px) {.dp-pageToolbar {flex-wrap: wrap;bottom: 0;left: 0;border-left: 0;border-bottom: 0;border-right: 0;width: 100%;}}.dp-pt-section {display: inline-block;white-space: nowrap;}.dp-pt-section .oo-ui-popupWidget-popup {/** Avoid preventing line breaks in popups */white-space: initial;}.dp-pt-section + .dp-pt-section {/* TODO: Recheck RTL compatibility */margin-left: 16px;padding-left: 16px;border-left: 1px solid gray;}.dp-pt-section:last-child {/* TODO: Recheck RTL compatibility */margin-right: 8px;}.dp-pt-section-label {font-weight: bold;font-size: 0.6rem;color: #4a5054;text-transform: uppercase;}.dp-pt-section-content .oo-ui-buttonElement:last-child {margin-right: 0;}.dp-pt-caseInfo {font-weight: bold;font-size: 1.3rem;pointer-events: none;}.dp-pt-missingRevision {white-space: normal;}.dp-pageToolbar .dp-cs-row-status {width: 5.4em;}.dp-pt-menu .oo-ui-menuSelectWidget {min-width: 300px;}.dp-pt-menu .oo-ui-menuOptionWidget {padding-top: 8px;padding-bottom: 8px;}";
var deputyCoreEnglish = {
"deputy.content.summary": "/* $1 */ -$2) (",
"deputy.content.summary.partial": "/* $1 */ - partial) (",
"deputy.content.summary.sectionClosed": "/* $1 */ -$2, section done) (",
"deputy.content.assessed": "Assessed $1 {{PLURAL:$1|revision|revisions}} across $2 pages",
"deputy.content.assessed.comma": ", ",
"deputy.content.assessed.finished": "$1 finished",
"deputy.content.assessed.reworked": "$1 reworked",
"deputy.content.assessed.sectionClosed": "section closed",
"deputy.content.reformat": "Reformatting section",
"deputy.session.start": "start CCI session",
"deputy.session.continue": "continue CCI session",
"deputy.session.continue.button": "Continue session",
"deputy.session.continue.head": "You last worked on this page on $1.",
"deputy.session.continue.help": "Continue working on \"$1\" and pick up where you left off.",
"deputy.session.continue.help.fromStart": "The section \"$1\" might have been archived already. Not to worry, you can being working on \"$2\".",
"deputy.session.tabActive.head": "You are working on this case page from another tab.",
"deputy.session.tabActive.help": "Deputy can only run on one case page and tab at a time. Navigate to the other tab to continue working.",
"deputy.session.otherActive.head": "Deputy is currently working on a different case page.",
"deputy.session.otherActive.help": "Deputy can only run on one case page and tab at a time. You may have also forgotten to close a prior Deputy session. You can force-stop the earlier session, but progress or data on the previously-open case may be lost.",
"deputy.session.otherActive.button": "Stop session",
"deputy.session.add": "Start working on this section",
"deputy.session.section.close": "Archive section",
"deputy.session.section.closeComments": "Archiving comments",
"deputy.session.section.closeCommentsSign": "Include my signature",
"deputy.session.section.closeError": "Some revisions remain unassessed. You must mark these revisions as assessed before archiving this section.",
"deputy.session.section.closeError.danger": "Some revisions remain unassessed, but Deputy will allow archiving while danger mode is enabled.",
"deputy.session.section.closeWarn": "You have unsaved changes. Close the section without saving?",
"deputy.session.section.closed": "This section has been archived. You can edit its contents, but you cannot un-archive it.",
"deputy.session.section.stop": "Stop session",
"deputy.session.section.stop.title": "Stop then current session, closing all sections and saving changes for later.",
"deputy.session.section.saved": "Section saved",
"deputy.session.section.failed": "Failed to save section",
"deputy.session.section.missingSection": "The target section is missing from the case page.",
"deputy.session.section.sectionIncomplete": "The target section still has unreviewed rows.",
"deputy.session.section.conflict.title": "Edit conflict",
"deputy.session.section.conflict.help": "Someone else edited the page before you. Deputy will restart to load the new case content. Your changes will be preserved.",
"deputy.session.section.danger": "Danger mode",
"deputy.session.section.markAllFinished": "Mark all revisions in all sections as finished",
"deputy.session.section.instantArchive": "Archive",
"deputy.session.section.instantArchive.title": "Archive and save this section immediately. Revisions will not be marked as finished.",
"deputy.session.row.status": "Current page status",
"deputy.session.row.status.unfinished": "Unfinished",
"deputy.session.row.status.unknown": "Unknown",
"deputy.session.row.status.withViolations": "Violations found",
"deputy.session.row.status.withoutViolations": "No violations found",
"deputy.session.row.status.missing": "Missing",
"deputy.session.row.status.presumptiveRemoval": "Presumptively removed",
"deputy.session.row.details.new": "created",
"deputy.session.row.details.edits": "{{PLURAL:$1|$1 edit|{{FORMATNUM:$1}} edits}}",
"deputy.session.row.content.open": "Expand additional details",
"deputy.session.row.content.close": "Collapse additional details",
"deputy.session.row.unfinishedWarning": "A assessment was given but not all revisions of the page have been assessed. The final assessment will not be saved on the case page until all revisions are marked as assessed, but will be saved locally (on your browser) for later.",
"deputy.session.row.talk": "Open the talk page",
"deputy.session.row.edit": "Edit this page",
"deputy.session.row.history": "Open page history",
"deputy.session.row.checkAll": "Mark all revisions as finished",
"deputy.session.row.checkAll.confirm": "Mark all revisions as finished?",
"deputy.session.row.additionalComments": "Discussion",
"deputy.session.row.closeComments": "Closing comments",
"deputy.session.row.close.sigFound": "The closing comment had a signature. It will not be automatically removed when saved.",
"deputy.session.row.close.sigFound.maybe": "The closing comment might have had a signature. It will not be automatically removed when saved.",
"deputy.session.row.error": "An error occurred while trying to get revision information: $1",
"deputy.session.row.checked": "Checked by $1",
"deputy.session.row.checkedComplete": "Checked by $1 on $2 ($3 ago)",
"deputy.session.row.checked.talk": "talk",
"deputy.session.row.checked.contribs": "contribs",
"deputy.session.row.pageonly": "This row does not contain any diffs. Please assess the page history manually.",
"deputy.session.revision.assessed": "Mark as assessed",
"deputy.session.revision.diff.toggle": "Toggle comparison (diff) view",
"deputy.session.revision.diff.error": "Failed to load comparison: $1",
"deputy.session.revision.cur": "cur",
"deputy.session.revision.prev": "prev",
"deputy.session.revision.cv": "cv",
"deputy.session.revision.cur.tooltip": "Difference with latest revision",
"deputy.session.revision.prev.tooltip": "Difference with preceding revision",
"deputy.session.revision.cv.tooltip": "Run through Earwig's Copyvio Detector",
"deputy.session.revision.talk": "talk",
"deputy.session.revision.contribs": "contribs",
"deputy.session.revision.bytes": "{{FORMATNUM:$1}} bytes",
"deputy.session.revision.byteChange": "{{FORMATNUM:$1}} bytes after change of this size",
"deputy.session.revision.tags": "{{PLURAL:$1|Tag|Tags}}:",
"deputy.session.revision.new": "N",
"deputy.session.revision.new.tooltip": "This edit created a new page.",
"deputy.session.revision.missing": "The revision [[Special:Diff/$1|$1]] could not be found. It may have been deleted or suppressed.",
"deputy.session.page.diff.previous": "Navigate to the previous unassessed revision",
"deputy.session.page.diff.next": "Navigate to the next unassessed revision",
"deputy.session.page.diff.loadFail": "Failed to load diff. Please check your internet connection and try again.",
"deputy.session.page.incommunicable": "Cannot find an active Deputy session. Please ensure that Deputy is running on a contributor investigation case page.",
"deputy.session.page.caseInfo.label": "Current case",
"deputy.session.page.caseInfo.revision": "Revision #$1",
"deputy.session.page.caseInfo.revision.none": "Revision out of scope",
"deputy.session.page.caseInfo.revision.help": "The requested revision was out of scope for the current Deputy case page. It was likely made by a user who is not a subject of the case page, or has already been removed or assessed by another user. Tools will still be available, but you will not be able to mark this revision as \"assessed\".",
"deputy.session.page.caseInfo.assessed": "Assessed?",
"deputy.session.page.caseInfo.next": "Navigate to the next unassessed revision",
"deputy.session.page.pageonly.title": "No revisions",
"deputy.session.page.pageonly.help": "This row does not contain any revisions with it. Please assess the page history manually before making an assessment.",
"deputy.session.page.analysis": "Analysis",
"deputy.session.page.earwigLatest": "Earwig's Copyvio Detector (latest)",
"deputy.session.page.earwigRevision": "Earwig's Copyvio Detector (revision)",
"deputy.session.page.earwigUnsupported": "Earwig's Copyvio Detector does not support this wiki.",
"deputy.session.page.iabot": "Add archives to a page (IABot)",
"deputy.session.page.iabot.reason": "Possible copyright violation investigation",
"deputy.session.page.tools": "Tools"
};
var deputyIaEnglish = {
"deputy.ia.content.respond": "Responding to [[$1#$2|$2]]",
"deputy.ia.content.close": "-1) (Responding to [[$1#$2|$2]]",
"deputy.ia.content.listing": "Adding listing for [[$1#$2|$2]]",
"deputy.ia.content.batchListing": "Adding batch listing: \"[[$1#$2|$2]]\"",
"deputy.ia.content.listingComment": "from $1. $2",
"deputy.ia.content.hideAll": "Hiding page content due to a suspected or complicated copyright issue",
"deputy.ia.content.hide": "Hiding sections [[$1#$2|$3]] to [[$1#$4|$5]] for suspected or complicated copyright issues",
"deputy.ia.content.listing.pd": "Adding listing for [[$1#$2|$2]] (presumptive deletion)",
"deputy.ia.content.batchListing.pd": "Adding batch listing: \"[[$1#$2|$2]]\" (presumptive deletion)",
"deputy.ia.content.listingComment.pd": "presumptive deletion from [[$1/$2|$2]]. $3",
"deputy.ia.content.batchListingComment.pd": "Presumptive deletion from [[$1/$2|$2]]. $3",
"deputy.ia.content.hideAll.pd": "Hiding page content for presumptive deletion; see [[$1/$2]]",
"deputy.ia.content.hide.pd": "Hiding sections [[$1#$2|$3]] to [[$1#$4|$5]] for presumptive deletion; see [[$6/$7]]",
"deputy.ia.content.copyvio": "⛔ Content on this page has been temporarily hidden due to a suspected copyright violation",
"deputy.ia.content.copyvio.help": "Please see this wiki's noticeboard for copyright problems for more information.",
"deputy.ia.content.copyvio.from": "The following reason/source was provided:",
"deputy.ia.content.copyvio.from.pd": "The content was presumptively removed based on the following contributor copyright investigation:",
"deputy.ia.content.copyvio.content": "The following content may be a copyright violation. Please do not unhide it unless you have determined that it is compatible with this wiki's copyright license.",
"deputy.ia.listing.new": "New listing",
"deputy.ia.listing.new.batch": "New batch listing",
"deputy.ia.listing.new.report": "Report",
"deputy.ia.listing.new.page.label": "Page to report",
"deputy.ia.listing.new.pages.label": "Pages to report",
"deputy.ia.listing.new.source.label": "Source of copied content",
"deputy.ia.listing.new.source.placeholder": "This page contains copyrighted content from ...",
"deputy.ia.listing.new.additionalNotes.label": "Additional notes",
"deputy.ia.listing.new.additionalNotes.placeholder": "Additional comments, context, or requests",
"deputy.ia.listing.new.title.label": "Batch title",
"deputy.ia.listing.new.title.placeholder": "Articles from ...",
"deputy.ia.listing.new.presumptive.label": "This is for presumptive deletion",
"deputy.ia.listing.new.presumptive.help": "Presumptive deletions are content removals where the actual source of copied content cannot be determined, but due to the history of the user, it is most likely a copyright violation. Enabling this will change related edit summaries and listing text.",
"deputy.ia.listing.new.presumptiveCase.label": "Case title",
"deputy.ia.listing.new.presumptiveCase.help": "The title of the case on a list of contributor copyright investigations. This is used to link to the case from the listing.",
"deputy.ia.listing.new.comments.label": "Batch listing comments",
"deputy.ia.listing.new.comments.placeholder": "Comments for each article",
"deputy.ia.listing.new.preview": "Preview",
"deputy.ia.listing.new.batchListed": "Batch listing posted",
"deputy.ia.listing.new.batchEr": "An error occurred while posting the batch listing: $1",
"deputy.ia.listing.respondPre": "[",
"deputy.ia.listing.respond": "respond",
"deputy.ia.listing.respondPost": "]",
"deputy.ia.listing.re.label": "Select a response...",
"deputy.ia.listing.re.title": "Prefilled listing comment",
"deputy.ia.listing.re.extras": "Additional comments",
"deputy.ia.listing.re.preview": "Preview",
"deputy.ia.listing.re.close": "Cancel",
"deputy.ia.listing.re.submit": "Respond",
"deputy.ia.listing.re.error": "An error occurred while attempting to respond to a listing: $1",
"deputy.ia.listing.re.published": "Your response has been published.",
"deputy.ia.listing.re.cleaned": "Article cleaned by investigator or others. No remaining infringement.",
"deputy.ia.listing.re.deletedcv": "Article deleted due to copyright concerns.",
"deputy.ia.listing.re.user": "User was not notified, relisting under today's entry.",
"deputy.ia.listing.re.where": "No vio found, claim cannot be validated. Tag removed from article.",
"deputy.ia.listing.re.unsure": "No source found; copy-paste tag removed and cv-unsure tag placed at article talk.",
"deputy.ia.listing.re.deletedcup": "Copyright concerns remain. Article deleted, left {{Template:Cup}} notice.",
"deputy.ia.listing.re.relist": "Permission plausible. Article relisted under today.",
"deputy.ia.listing.re.resolved": "Issue resolved.",
"deputy.ia.listing.re.redirect": "Article redirected to a non-infringing target.",
"deputy.ia.listing.re.deletedother": "Article deleted for a reason other than copyright concerns.",
"deputy.ia.listing.re.move": "Rewrite moved into place.",
"deputy.ia.listing.re.backwardsattributed": "Backwardscopy. Attributes Wikipedia.",
"deputy.ia.listing.re.blanked": "Blanked and relisted under today.",
"deputy.ia.listing.re.deferred": "Deferred to old issues.",
"deputy.ia.listing.re.ticket": "VRT Ticket received, article now licensed and compatible with CC BY-SA 3.0.",
"deputy.ia.listing.re.backwards": "Backwardscopy. Tag placed at talk page.",
"deputy.ia.listing.re.no": "No copyright concern. Material is PD, license compatible, or ineligible for copyright protection.",
"deputy.ia.listing.re.histpurge": "Article cleaned, revision deletion requested.",
"deputy.ia.listing.re.OTRS": "VRT pending but not yet verified, relisting under today's entry.",
"deputy.ia.listing.re.purged": "Revision deletion completed. Copyright problem removed from history.",
"deputy.ia.listing.re.unverified": "Permission unverified as of this tagging; article will need to be deleted if that does not change.",
"deputy.ia.listing.re.viable": "Viable rewrite proposed; rewrite on temp page can be merged into the article.",
"deputy.ia.report.intro": "You are reporting to <b>[[$1]]</b>",
"deputy.ia.report.page": "Currently reporting <b>[[$1]]</b>",
"deputy.ia.report.lead": "Lead section",
"deputy.ia.report.end": "End of page",
"deputy.ia.report.section": "$1: $2",
"deputy.ia.report.transcludedSection": "This section is transcluded from another page, \"$1\".",
"deputy.ia.report.entirePage.label": "Hide the entire page",
"deputy.ia.report.startSection.placeholder": "Select starting section to hide",
"deputy.ia.report.startSection.label": "Starting section",
"deputy.ia.report.endSection.placeholder": "Select ending section to hide",
"deputy.ia.report.endSection.label": "Ending section",
"deputy.ia.report.endSection.help": "This setting is inclusive, meaning it will also hide the section indicated.",
"deputy.ia.report.presumptive.label": "This is for presumptive deletion",
"deputy.ia.report.presumptive.help": "Presumptive deletions are content removals where the actual source of copied content cannot be determined, but due to the history of the user, it is most likely a copyright violation. Enabling this will change related edit summaries and listing text.",
"deputy.ia.report.presumptiveCase.label": "Case title",
"deputy.ia.report.presumptiveCase.help": "The title of the case on a list of contributor copyright investigations. This is used to link to the case from the listing.",
"deputy.ia.report.fromUrls.label": "Content copied from online sources",
"deputy.ia.report.fromUrls.help": "URLs will automatically be wrapped with brackets to shorten the external link. Disabling this option will present the text as is.",
"deputy.ia.report.source.label": "Source of copied content",
"deputy.ia.report.sourceUrls.placeholder": "Add URLs",
"deputy.ia.report.sourceText.placeholder": "This page contains copyrighted content from ...",
"deputy.ia.report.additionalNotes.label": "Additional notes",
"deputy.ia.report.additionalNotes.placeholder": "Additional comments, context, or requests",
"deputy.ia.report.submit": "Submit",
"deputy.ia.report.hide": "Hide content only",
"deputy.ia.report.hide.confirm": "This will insert the {{Template:copyvio}} template and hide page content as set, but will not post a listing for this page on the noticeboard. Are you sure you don't want to list this page on the noticeboard?",
"deputy.ia.report.success": "Page content hidden and reported",
"deputy.ia.report.success.hide": "Page content hidden",
"deputy.ia.report.success.report": "Page reported",
"deputy.ia.report.error.report": "An error occurred while trying to save the entry to today's noticeboard listings. Please visit the noticeboard page and select \"Add listing\" or file the listing manually.",
"deputy.ia.report.error.shadow": "An error occurred while trying to append the {{Template:copyvio}} template on the page. Please manually insert the template.",
"deputy.ia.hiddenVio": "A user has marked content on this page as a suspected copyright violation. It is currently hidden from normal viewers of this page while awaiting further action.",
"deputy.ia.hiddenVio.show": "Show hidden content",
"deputy.ia.hiddenVio.hide": "Hide hidden content"
};
/**
* A class that represents a `Wikipedia:Copyright problems` page, a page that lists
* a collection of accumulated copyright problems found on Wikipedia. Users who are
* not well-versed in copyright can submit listings there to be reviewed by more-
* knowledgeable editors.
*
* This page can refer to any Copyright problems page, and not necessarily one that
* is running on the current tab. For that, CopyrightProblemsSession is used.
*/
class CopyrightProblemsPage {
/**
* Private constructor. Use `get` instead to avoid cache misses.
*
* @param listingPage
* @param revid
*/
constructor(listingPage, revid) {
this.title = listingPage;
this.main = CopyrightProblemsPage.rootPage.getPrefixedText() ===
listingPage.getPrefixedText();
this.revid = revid;
}
/**
* @return See {@link WikiConfiguration#ia}.rootPage.
*/
static get rootPage() {
return window.InfringementAssistant.wikiConfig.ia.rootPage.get();
}
/**
* @return The title of the current copyright problems subpage.
*/
static getCurrentListingPage() {
return normalizeTitle(CopyrightProblemsPage.rootPage.getPrefixedText() + '/' +
window.moment().utc().format(window.InfringementAssistant.wikiConfig.ia.subpageFormat.get()));
}
/**
* @param title The title to check
* @return `true` if the given page is a valid listing page.
*/
static isListingPage(title = mw.config.get('wgPageName')) {
return normalizeTitle(title)
.getPrefixedText()
.startsWith(CopyrightProblemsPage.rootPage.getPrefixedText());
}
/**
* Gets the current CopyrightProblemsPage (on Copyright Problems listing pages)
*
* @return A CopyrightProblemsPage for the current page.
*/
static getCurrent() {
const listingPage = this.getCurrentListingPage();
return new CopyrightProblemsPage(listingPage);
}
/**
* Gets a listing page from the cache, if available. If a cached page is not available,
* it will be created for you.
*
* @param listingPage
* @param revid
* @return The page requested
*/
static get(listingPage, revid) {
const key = listingPage.getPrefixedDb() + '##' + (revid !== null && revid !== void 0 ? revid : 0);
if (CopyrightProblemsPage.pageCache.has(key)) {
return CopyrightProblemsPage.pageCache.get(key);
}
else {
const page = new CopyrightProblemsPage(listingPage, revid);
CopyrightProblemsPage.pageCache.set(key, page);
return page;
}
}
/**
* @param force
* @return the current wikitext of the page
*/
getWikitext(force = false) {
return __awaiter(this, void 0, void 0, function* () {
if (this.wikitext && !force) {
return this.wikitext;
}
const content = yield getPageContent(this.title);
if (content == null) {
return null;
}
this.revid = content.revid;
this.wikitext = content;
return content;
});
}
/**
* Handles appends to new listings. Also handles cases where the listing
* page is missing. If the listing is today's listing page, but the page is missing,
* the page will automatically be created with the proper header. If the listing is
* NOT today's page and is missing, this will throw an error.
*
* If the page was not edited since the page was missing, and the page was created
* in the time it took for us to find out that the page was missing (i.e., race
* condition), it will attempt to proceed with the original appending. If the edit
* still fails, an error is thrown.
*
* @param content The content to append
* @param summary The edit summary to use when appending
* @param appendMode
*/
tryListingAppend(content, summary, appendMode = true) {
return __awaiter(this, void 0, void 0, function* () {
const listingPage = this.main ? CopyrightProblemsPage.getCurrentListingPage() : this.title;
if (
// Current listing page is automatically used for this.main, so this can be
// an exception.
!this.main &&
// If the listing page is today's listing page.
CopyrightProblemsPage.getCurrentListingPage().getPrefixedText() !==
listingPage.getPrefixedText() &&
// Not on append mode (will create page)
!appendMode) {
// It's impossible to guess the header for the page at this given moment in time,
// so simply throw an error. In any case, this likely isn't the right place to
// post the listing in the first place.
throw new Error('Attempted to post listing on non-current page');
}
const config = yield window.InfringementAssistant.getWikiConfig();
const preloadText = config.ia.preload.get() ? `{{subst:${
// Only trim last newline, if any.
config.ia.preload.get().replace(/\n$/, '')}}}\n` : '';
const textParameters = appendMode ? {
appendtext: '\n' + content,
nocreate: true
} : {
text: preloadText + content,
createonly: true
};
// The `catch` statement here can theoretically create an infinite loop given
// enough race conditions. Don't worry about it too much, though.
yield MwApi.action.postWithEditToken(Object.assign(Object.assign(Object.assign(Object.assign({}, changeTag(yield window.InfringementAssistant.getWikiConfig())), { action: 'edit', title: listingPage.getPrefixedText() }), textParameters), { summary })).then(() => {
// Purge the main listing page.
return MwApi.action.post({
action: 'purge',
titles: CopyrightProblemsPage.rootPage.getPrefixedText()
});
}).catch((code) => {
if (code === 'articleexists') {
// Article exists on non-append mode. Attempt a normal append.
this.tryListingAppend(content, summary, true);
}
else if (code === 'missingtitle') {
// Article doesn't exist on append mode. Attempt a page creation.
this.tryListingAppend(content, summary, false);
}
else {
// wat.
throw code;
}
});
yield this.getWikitext(true);
});
}
/**
* Posts a single page listing to this page, or (if on the root page), the page for
* the current date. Listings are posted in the following format:
* ```
* * {{subst:article-cv|Example}} <comment> ~~~~
* ```
*
* For posting multiple pages, use `postListings`.
*
* @param page
* @param comments
* @param presumptive
*/
postListing(page, comments, presumptive) {
return __awaiter(this, void 0, void 0, function* () {
const listingPage = this.main ? CopyrightProblemsPage.getCurrentListingPage() : this.title;
yield this.tryListingAppend(this.getListingWikitext(page, comments), decorateEditSummary(mw.msg(presumptive ?
'deputy.ia.content.listing.pd' :
'deputy.ia.content.listing', listingPage.getPrefixedText(), page.getPrefixedText()), window.InfringementAssistant.config));
});
}
/**
* Generates the listing wikitext using wiki configuration values.
*
* @param page
* @param comments
* @return Wikitext
*/
getListingWikitext(page, comments) {
return mw.format(window.InfringementAssistant.wikiConfig.ia.listingWikitext.get(), page.getPrefixedText(), comments || '').replace(/(\s){2,}/g, '$1');
}
/**
* Posts multiple pages under a collective listing. Used for cases where the same
* comment can be applied to a set of pages. Listings are posted in the following
* format:
* ```
* ;{{anchor|1=<title>}}<title>
* * {{subst:article-cv|1=Page 1}}
* * {{subst:article-cv|1=Page 2}}
* <comment> ~~~~
* ```
*
* @param page
* @param title
* @param comments
* @param presumptive
*/
postListings(page, title, comments, presumptive) {
return __awaiter(this, void 0, void 0, function* () {
const listingPage = this.main ? CopyrightProblemsPage.getCurrentListingPage() : this.title;
yield this.tryListingAppend(this.getBatchListingWikitext(page, title, comments), decorateEditSummary(mw.msg(presumptive ?
'deputy.ia.content.batchListing.pd' :
'deputy.ia.content.batchListing', listingPage.getPrefixedText(), title), window.InfringementAssistant.config));
});
}
/**
* Generates the batch listing wikitext using wiki configuration values.
*
* @param page
* @param title
* @param comments
* @return Wikitext
*/
getBatchListingWikitext(page, title, comments) {
const pages = page
.map((p) => mw.format(window.InfringementAssistant.wikiConfig.ia.batchListingPageWikitext.get(), p.getPrefixedText()))
.join('');
return mw.format(window.InfringementAssistant.wikiConfig.ia.batchListingWikitext.get(), title, pages, comments || '').replace(/^\s+~~~~$/gm, '~~~~');
}
}
CopyrightProblemsPage.pageCache = new Map();
/**
* Extracts a page title from a MediaWiki `<a>`. If the link does not validly point
* to a MediaWiki page, `false` is returned.
*
* The part of the link used to determine the page title depends on how trustworthy
* the data is in telling the correct title. If the link does not have an `href`, only
* two routes are available: the selflink check and the `title` attribute check.
*
* The following methods are used, in order.
* - `title` parameter from anchor href
* - `/wiki/$1` path from anchor href
* - `./$1` path from Parsoid document anchor href
* - selflinks (not run on Parsoid)
* - `title` attribute from anchor
*
* @param el
* @return the page linked to
*/
function pagelinkToTitle(el) {
const href = el.getAttribute('href');
const articlePathRegex = new RegExp(mw.util.getUrl('(.*)'));
if (href && href.startsWith(mw.util.wikiScript('index'))) {
// The link matches the script path (`/w/index.php`).
// This is the branch used in cases where the page does not exist. The section is always
// dropped from the link, so no section filtering needs to be done.
// Attempt to extract page title from `title` parameter.
const titleRegex = /[?&]title=(.*?)(?:&|$)/;
if (titleRegex.test(href)) {
return new mw.Title(titleRegex.exec(href)[1]);
}
else {
// Not a valid link.
return false;
}
}
if (href && articlePathRegex.test(href)) {
// The link matches the article path (`/wiki/$1`) RegExp.
return new mw.Title(decodeURIComponent(articlePathRegex.exec(href)[1]));
}
if (el.getAttribute('rel') === 'mw:WikiLink') {
// Checks for Parsoid documents.
if (href) {
const parsoidHrefMatch = articlePathRegex.exec(href.replace(/^\.\/([^#]+).*$/, mw.config.get('wgArticlePath')));
if (parsoidHrefMatch != null) {
// The link matches the Parsoid link format (`./$1`).
return new mw.Title(decodeURIComponent(href.slice(2)));
}
}
}
else {
// Checks for non-Parsoid documents
if (el.classList.contains('mw-selflink')) {
// Self link. Return current page name.
return new mw.Title(el.ownerDocument.defaultView.mw.config.get('wgPageName'));
}
}
// If we still can't find a title by this point, rely on the `title` attribute.
// This is unstable, since the title may be set or modified by other userscripts, so it
// is only used as a last resort.
if (el.hasAttribute('title') &&
// Not a redlink
!el.classList.contains('new') &&
// Not an external link
!el.classList.contains('external')) {
return new mw.Title(el.getAttribute('title'));
}
// Not a valid link.
return false;
}
/**
* Check if a given copyright problems listing is full.
*
* @param data
* @return `true` if the listing is a {@link FullCopyrightProblemsListingData}
*/
function isFullCopyrightProblemsListing(data) {
return data.basic === false;
}
/**
* Represents an <b>existing</b> copyright problems listing. To add or create new
* listings, use the associated functions in {@link CopyrightProblemsPage}.
*/
class CopyrightProblemsListing {
/**
* Creates a new listing object.
*
* @param data Additional data about the page
* @param listingPage The page that this listing is on. This is not necessarily the page that
* the listing's wikitext is on, nor is it necessarily the root page.
* @param i A discriminator used to avoid collisions when a page is listed multiple times.
*/
constructor(data, listingPage, i = 1) {
this.listingPage = listingPage !== null && listingPage !== void 0 ? listingPage : CopyrightProblemsPage.get(data.listingPage);
this.i = Math.max(1, i); // Ensures no value below 1.
this.basic = data.basic;
this.title = data.title;
this.element = data.element;
if (data.basic === false) {
this.id = data.id;
this.anchor = data.anchor;
this.plainlinks = data.plainlinks;
}
}
/**
* Responsible for determining listings on a page. This method allows for full-metadata
* listing detection, and makes the process of detecting a given listing much more precise.
*
* This regular expression must catch three groups:
* - $1 - The initial `* `, used to keep the correct number of whitespace between parts.
* - $2 - The page title in the `id="..."`, ONLY IF the page is listed with an
* `article-cv`-like template.
* - $3 - The page title in the wikilink, ONLY IF the page is listed with an
* `article-cv`-like template.
* - $4 - The page title, ONLY IF the page is a bare link to another page and does not use
* `article-cv`.
*
* @return A regular expression.
*/
static get articleCvRegex() {
// Acceptable level of danger; global configuration is found only in trusted
// places (see WikiConfiguration documentation).
// eslint-disable-next-line security/detect-non-literal-regexp
return new RegExp(window.InfringementAssistant.wikiConfig.ia.listingWikitextMatch.get());
}
/**
* Gets the page title of the listing page. This is used in `getListing` and
* `getBasicListing` to identify which page the listings are on.
*
* This makes the assumption that all listings have a prior H4 header that
* links to the proper listing page. If that assumption is not met, this
* returns `null`.
*
* @param el
* @return The page title, or `false` if none was found.
* @private
*/
static getListingHeader(el) {
var _a;
let listingPage = null;
let previousPivot = (
// Target the ol/ul element itself if a list, target the <p> if not a list.
el.parentElement.tagName === 'LI' ? el.parentElement.parentElement : el.parentElement).previousElementSibling;
let heading;
// Search for a level 4 heading backwards.
while (previousPivot != null &&
// Set the ceiling to be immediately above for efficiency.
((_a = (heading = normalizeWikiHeading(previousPivot, previousPivot.parentElement))) === null || _a === void 0 ? void 0 : _a.level) !== 4) {
previousPivot = previousPivot.previousElementSibling;
}
if (previousPivot == null) {
return false;
}
// At this point, previousPivot is likely a MediaWiki level 4 heading.
const h4Anchor = heading.h.querySelector('a');
if (h4Anchor) {
listingPage = pagelinkToTitle(h4Anchor);
// Identify if the page is a proper listing page (within the root page's
// pagespace)
if (!listingPage ||
!listingPage.getPrefixedText()
.startsWith(CopyrightProblemsPage.rootPage.getPrefixedText())) {
return false;
}
}
return listingPage !== null && listingPage !== void 0 ? listingPage : false;
}
/**
* Determines if a given element is a valid anchor element (`<a>`) which
* makes up a "listing" (a page for checking on the Copyright Problems page).
*
* Detection is based on the {{article-cv}} template. Changes to the template
* must be reflected here, with backwards compatibility for older listings.
* The {{anchor}} is not the tracked element here, since it remains invisible
* to the user.
*
* @param el
* @return Data related to the listing, for use in instantiation; `false` if not a listing.
*/
static getListing(el) {
try {
if (el.tagName !== 'A' || el.getAttribute('href') === null) {
// Not a valid anchor element.
return false;
}
// Check for {{anchor}} before the link.
const anchor = el.previousElementSibling;
if (anchor == null || anchor.tagName !== 'SPAN') {
return false;
}
// Get the page title based on the anchor, verified by the link.
// This ensures we're always using the prefixedDb version of the title (as
// provided by the anchor) for stability.
const id = anchor.getAttribute('id');
const title = pagelinkToTitle(el);
if (title === false || id == null) {
// Not a valid link.
return false;
}
else if (title.getPrefixedText() !== new mw.Title(id).getPrefixedText()) {
// Anchor and link mismatch. Someone tampered with the template?
// In this case, rely on the link instead, as the anchor is merely invisible.
warn(`Anchor and link mismatch for "${title.getPrefixedText()}".`, title, id);
}
// Checks for the <span class="plainlinks"> element.
// This ensures that the listing came from {{article-cv}} and isn't just a
// link with an anchor.
const elSiblings = Array.from(el.parentElement.children);
const elIndex = elSiblings.indexOf(el);
const plainlinks = el.parentElement.querySelector(`:nth-child(${elIndex}) ~ span.plainlinks`);
if (plainlinks == null ||
// `~` never gets an earlier element, so just check if it's more than 2 elements
// away.
elSiblings.indexOf(plainlinks) - elIndex > 2) {
return false;
}
// Attempts to look for a prior <h4> tag. Used for determining the listing, if on a
// root page.
const listingPage = this.getListingHeader(el);
if (!listingPage) {
// Can't find a proper listing page for this. In some cases, this
// should be fine, however we don't want the [respond] button to
// appear if we don't know where a page is actually listed.
return false;
}
return {
basic: false,
id,
title,
listingPage,
element: el,
anchor: anchor,
plainlinks: plainlinks
};
}
catch (e) {
warn("Couldn't parse listing. Might be malformed?", e, el);
return false;
}
}
/**
* A much more loose version of {@link CopyrightProblemsListing#getListing},
* which only checks if a given page is a link at the start of a paragraph or
* `<[uo]l>` list. Metadata is unavailable with this method.
*
* @param el
* @return Data related to the listing, for use in instantiation; `false` if not a listing.
*/
static getBasicListing(el) {
try {
if (el.tagName !== 'A' || el.getAttribute('href') == null) {
// Not a valid anchor element.
return false;
}
// Check if this is the first node in the container element.
if (el.previousSibling != null) {
return false;
}
// Check if the container is a paragraph or a top-level ul/ol list item.
if (el.parentElement.tagName !== 'P' &&
(el.parentElement.tagName !== 'LI' && (el.parentElement.parentElement.tagName !== 'UL' &&
el.parentElement.parentElement.tagName !== 'OL'))) {
return false;
}
// Attempt to extract page title.
const title = pagelinkToTitle(el);
if (!title) {
return false;
}
// Attempts to look for a prior <h4> tag. Used for determining the listing, if on a
// root page.
const listingPage = this.getListingHeader(el);
if (!listingPage) {
// Can't find a proper listing page for this. In some cases, this
// should be fine, however we don't want the [respond] button to
// appear if we don't know where a page is actually listed.
return false;
}
return {
basic: true,
title,
listingPage,
element: el
};
}
catch (e) {
warn("Couldn't parse listing. Might be malformed?", e, el);
return false;
}
}
/**
* @return an ID representation of this listing. Helps in finding it inside of
* wikitext.
*/
get anchorId() {
return this.id + (this.i > 1 ? `-${this.i}` : '');
}
/**
* Gets the line number of a listing based on the page's wikitext.
* This is further used when attempting to insert comments to listings.
*
* This provides an object with `start` and `end` keys. The `start` denotes
* the line on which the listing appears, the `end` denotes the last line
* where there is a comment on that specific listing.
*
* Use in conjunction with `listingPage.getWikitext()` to get the lines in wikitext.
*
* @return See documentation body.
*/
getListingWikitextLines() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const lines = (yield this.listingPage.getWikitext()).split('\n');
let skipCounter = 1;
let startLine = null;
let endLine = null;
let bulletList;
const normalizedId = normalizeTitle((_a = this.id) !== null && _a !== void 0 ? _a : this.title).getPrefixedText();
const idMalformed = normalizedId !== this.title.getPrefixedText();
for (let line = 0; line < lines.length; line++) {
const lineText = lines[line];
// Check if this denotes the end of a listing.
// Matches: `*:`, `**`
// Does not match: `*`, ``, ` `
if (startLine != null) {
if (bulletList ?
!/^(\*[*:]+|:)/g.test(lineText) :
/^[^:*]/.test(lineText)) {
return { start: startLine, end: endLine !== null && endLine !== void 0 ? endLine : startLine };
}
else {
endLine = line;
}
}
else {
const match = cloneRegex$1(CopyrightProblemsListing.articleCvRegex)
.exec(lineText);
if (match != null) {
if (normalizeTitle(match[2] || match[4]).getPrefixedText() !==
normalizedId) {
continue;
}
// Check if this should be skipped.
if (skipCounter < this.i) {
// Skip if we haven't skipped enough.
skipCounter++;
continue;
}
if (idMalformed && match[2] === match[3]) {
throw new Error(`Expected malformed listing with ID "${normalizedId}" and title "${this.title.getPrefixedText()}" but got normal listing.`);
}
bulletList = /[*:]/.test((match[1] || '').trim());
startLine = line;
}
}
}
// We've reached the end of the document.
// `startLine` is only ever set if the IDs match, so we can safely assume
// that if `startLine` and `endLine` is set or if `startLine` is the last line
// in the page, then we've found the listing (and it is the last listing on the
// page, where `endLine` would have been set if it had comments).
if ((startLine != null && endLine != null) ||
(startLine != null && startLine === lines.length - 1)) {
return { start: startLine, end: endLine !== null && endLine !== void 0 ? endLine : startLine };
}
// Couldn't find an ending. Malformed listing?
// It should be nearly impossible to hit this condition.
// Gracefully handle this.
throw new Error("Couldn't detect listing from wikitext (edit conflict/is it missing?)");
});
}
/**
* Adds a comment to an existing listing.
*
* @param message
* @param indent
* @return the modified page wikitext.
*/
addComment(message, indent = false) {
return __awaiter(this, void 0, void 0, function* () {
const lines = (yield this.listingPage.getWikitext()).split('\n');
const range = yield this.getListingWikitextLines();
if (indent) {
// This usually isn't needed. {{CPC}} handles the bullet.
message = (this.element.parentElement.tagName === 'LI' ?
'*:' :
':') + message;
}
lines.splice(range.end + 1, 0, message);
return lines.join('\n');
});
}
/**
* Adds a comment to an existing listing AND saves the page. To avoid saving the page,
* use `addComment` instead.
*
* @param message
* @param summary
* @param indent
*/
respond(message, summary, indent = false) {
return __awaiter(this, void 0, void 0, function* () {
const newWikitext = yield this.addComment(message, indent);
yield MwApi.action.postWithEditToken(Object.assign(Object.assign({}, changeTag(yield window.InfringementAssistant.getWikiConfig())), { action: 'edit', format: 'json', formatversion: '2', utf8: 'true', title: this.listingPage.title.getPrefixedText(), text: newWikitext, summary: decorateEditSummary(summary !== null && summary !== void 0 ? summary : mw.msg('deputy.ia.content.respond', this.listingPage.title.getPrefixedText(), this.title.getPrefixedText()), window.InfringementAssistant.config) }));
yield this.listingPage.getWikitext(true);
});
}
/**
* Serialize this listing. Used for tests.
*/
serialize() {
return __awaiter(this, void 0, void 0, function* () {
return {
basic: this.basic,
i: this.i,
id: this.id,
title: {
namespace: this.title.namespace,
title: this.title.title,
fragment: this.title.getFragment()
},
listingPage: {
namespace: this.listingPage.title.namespace,
title: this.listingPage.title.title,
fragment: this.listingPage.title.getFragment()
},
lines: yield this.getListingWikitextLines()
};
});
}
}
/**
*
*/
class ListingResponsePanel extends EventTarget {
/**
*
* @param originLink
* @param listing
*/
constructor(originLink, listing) {
super();
// TODO: types-mediawiki limitation
this.reloadPreviewThrottled = mw.util.throttle(this.reloadPreview, 500);
this.originLink = originLink;
this.listing = listing;
}
/**
* @return A set of possible copyright problems responses.
*/
static get responses() {
return window.InfringementAssistant.wikiConfig.ia.responses.get();
}
/**
*
* @param response
* @param locale
* @return The given response for the given locale
*/
static getResponseLabel(response, locale) {
var _a, _b, _c;
if (!locale) {
locale = (_a = window.deputyLang) !== null && _a !== void 0 ? _a : mw.config.get('wgUserLanguage');
}
const locale1 = locale.replace(/-.*$/g, '');
return typeof response.label === 'string' ?
response.label :
((_c = (_b = response.label[locale]) !== null && _b !== void 0 ? _b : response.label[locale1]) !== null && _c !== void 0 ? _c : response.label[0]);
}
/**
* @return The edit summary for this edit.
*/
getEditSummary() {
var _a;
return ((_a = this.prefill) === null || _a === void 0 ? void 0 : _a.closing) === false ? mw.msg('deputy.ia.content.respond', this.listing.listingPage.title.getPrefixedText(), this.listing.title.getPrefixedText()) : mw.msg('deputy.ia.content.close', this.listing.listingPage.title.getPrefixedText(), this.listing.title.getPrefixedText());
}
/**
* Renders the response dropdown.
*
* @return An unwrapped OOUI DropdownInputWidget.
*/
renderPrefillDropdown() {
const options = [{
data: null,
label: mw.msg('deputy.ia.listing.re.label'),
disabled: true
}];
for (const responseId in ListingResponsePanel.responses) {
const response = ListingResponsePanel.responses[responseId];
options.push({
data: `${responseId}`,
label: ListingResponsePanel.getResponseLabel(response)
});
}
this.dropdown = new OO.ui.DropdownInputWidget({
options,
dropdown: {
label: mw.msg('deputy.ia.listing.re.label'),
title: mw.msg('deputy.ia.listing.re.title')
}
});
this.dropdown.on('change', (value) => {
this.prefill = ListingResponsePanel.responses[+value];
this.reloadPreviewThrottled();
});
return unwrapWidget(this.dropdown);
}
/**
* @return An unwrapped OOUI TextInputWidget
*/
renderAdditionalCommentsField() {
this.commentsField = new OO.ui.MultilineTextInputWidget({
placeholder: mw.msg('deputy.ia.listing.re.extras'),
autosize: true,
rows: 1
});
this.commentsField.on('change', (text) => {
this.comments = text;
this.reloadPreviewThrottled();
});
return unwrapWidget(this.commentsField);
}
/**
* @return An unwrapped OOUI ButtonWidget.
*/
renderCloseButton() {
const closeButton = new OO.ui.ButtonWidget({
flags: ['destructive'],
label: mw.msg('deputy.ia.listing.re.close'),
framed: true
});
closeButton.on('click', () => {
this.close();
});
return unwrapWidget(closeButton);
}
/**
* @return An unwrapped OOUI ButtonWidget.
*/
renderSubmitButton() {
this.submitButton = new OO.ui.ButtonWidget({
flags: ['progressive', 'primary'],
label: mw.msg('deputy.ia.listing.re.submit'),
disabled: true
});
this.submitButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
this.dropdown.setDisabled(true);
this.commentsField.setDisabled(true);
this.submitButton.setDisabled(true);
try {
yield this.listing.respond(this.toWikitext(), this.getEditSummary(), false);
const dd = h_1("dd", { dangerouslySetInnerHTML: this.previewPanel.innerHTML });
dd.querySelectorAll('.deputy')
.forEach((v) => removeElement(v));
// Try to insert at an existing list for better spacing.
if (this.element.previousElementSibling.tagName === 'DL') {
this.element.previousElementSibling.appendChild(dd);
}
else {
this.element.insertAdjacentElement('afterend', h_1("dl", { class: "ia-newResponse" }, dd));
}
this.close();
mw.notify(mw.msg('deputy.ia.listing.re.published'), {
type: 'success'
});
}
catch (e) {
error(e);
OO.ui.alert(mw.msg('deputy.ia.listing.re.error', e.message));
this.dropdown.setDisabled(false);
this.commentsField.setDisabled(false);
this.submitButton.setDisabled(false);
}
}));
return unwrapWidget(this.submitButton);
}
/**
* Reloads the preview.
*/
reloadPreview() {
const wikitext = this.toWikitext();
if (wikitext == null) {
this.previewPanel.style.display = 'none';
}
else {
this.previewPanel.style.display = '';
}
renderWikitext(wikitext, this.listing.listingPage.title.getPrefixedText(), {
pst: true,
summary: this.getEditSummary()
}).then((data) => {
var _a, _b;
this.previewPanel.innerHTML = data;
const cpcContent = this.previewPanel.querySelector('ul > li > dl > dd');
if (cpcContent) {
// Extract ONLY the actual text.
this.previewPanel.innerHTML = cpcContent.innerHTML;
}
// Infuse collapsibles
(_b = (_a = $(this.previewPanel).find('.mw-collapsible')).makeCollapsible) === null || _b === void 0 ? void 0 : _b.call(_a);
$(this.previewPanel).find('.collapsible')
.each((i, e) => {
var _a, _b;
(_b = (_a = $(e)).makeCollapsible) === null || _b === void 0 ? void 0 : _b.call(_a, {
collapsed: e.classList.contains('collapsed')
});
});
// Add in "summary" row.
this.previewPanel.insertAdjacentElement('afterbegin', h_1("div", { class: "deputy", style: {
fontSize: '0.9em',
borderBottom: '1px solid #c6c6c6',
marginBottom: '0.5em',
paddingBottom: '0.5em'
} },
"Summary: ",
h_1("i", null,
"(",
h_1("span", { class: "mw-content-text", dangerouslySetInnerHTML: data.summary }),
")")));
// Make all anchor links open in a new tab (prevents exit navigation)
this.previewPanel.querySelectorAll('a')
.forEach((el) => {
if (el.hasAttribute('href')) {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
}
});
});
}
/**
* @return A wikitext representation of the response generated by this panel.
*/
toWikitext() {
var _a, _b, _c;
if (this.prefill == null && this.comments == null) {
(_a = this.submitButton) === null || _a === void 0 ? void 0 : _a.setDisabled(true);
return null;
}
else {
(_b = this.submitButton) === null || _b === void 0 ? void 0 : _b.setDisabled(false);
}
return this.prefill ?
mw.format(this.prefill.template, this.listing.title, (_c = this.comments) !== null && _c !== void 0 ? _c : '') :
this.comments;
}
/**
* @return The listing panel
*/
render() {
return this.element = h_1("div", { class: "ia-listing-response" },
h_1("div", { class: "ia-listing-response--dropdown" }, this.renderPrefillDropdown()),
h_1("div", { class: "ia-listing-response--comments" }, this.renderAdditionalCommentsField()),
this.previewPanel = h_1("div", { class: "ia-listing--preview", "data-label": mw.msg('deputy.ia.listing.re.preview'), style: 'display: none' }),
h_1("div", { class: "ia-listing-response--submit" },
this.renderCloseButton(),
this.renderSubmitButton()));
}
/**
* Announce closure of this panel and remove it from the DOM.
*/
close() {
this.dispatchEvent(new Event('close'));
removeElement(this.element);
}
}
/**
*
* @param session
* @param listing
* @return An HTML element
*/
function ListingActionLink(session, listing) {
const element = h_1("div", { class: "ia-listing-action" },
h_1("span", { class: "ia-listing-action--bracket" }, mw.msg('deputy.ia.listing.respondPre')),
h_1("a", { class: "ia-listing-action--link", role: "button", href: "", onClick: (event) => __awaiter(this, void 0, void 0, function* () {
const target = event.currentTarget;
target.toggleAttribute('disabled', true);
mw.loader.using(window.InfringementAssistant.static.dependencies, () => {
const panel = new ListingResponsePanel(element, listing);
listing.element.parentElement.appendChild(panel.render());
element.style.display = 'none';
panel.addEventListener('close', () => {
element.style.display = '';
});
target.toggleAttribute('disabled', false);
});
}) }, mw.msg('deputy.ia.listing.respond')),
h_1("span", { class: "ia-listing-action--bracket" }, mw.msg('deputy.ia.listing.respondPost')));
return element;
}
let InternalCCICaseInputWidget;
/**
* Initializes the process element.
*/
function initCCICaseInputWidget() {
InternalCCICaseInputWidget = class CCICaseInputWidget extends mw.widgets.TitleInputWidget {
/**
*
* @param config
*/
constructor(config) {
super(Object.assign(Object.assign({}, config), { inputFilter: (value) => {
const prefix = window.InfringementAssistant.wikiConfig
.cci.rootPage.get().getPrefixedText() + '/';
// Simple replace, only 1 replacement made anyway.
const trimmed = value.replace(prefix, '').trimStart();
if (config.inputFilter) {
return config.inputFilter(trimmed);
}
else {
return trimmed;
}
} }));
this.getQueryValue = function () {
return `${window.InfringementAssistant.wikiConfig.cci.rootPage.get()
.getPrefixedText()}/${this.getValue().trimEnd()}`;
};
}
};
}
/**
* Creates a new CCICaseInputWidget.
*
* @param config Configuration to be passed to the element.
* @return A CCICaseInputWidget object
*/
function CCICaseInputWidget (config) {
if (!InternalCCICaseInputWidget) {
initCCICaseInputWidget();
}
return new InternalCCICaseInputWidget(config);
}
let InternalSinglePageWorkflowDialog;
/**
* Initializes the process element.
*/
function initSinglePageWorkflowDialog() {
var _a;
InternalSinglePageWorkflowDialog = (_a = class SinglePageWorkflowDialog extends OO.ui.ProcessDialog {
/**
* @param config Configuration to be passed to the element.
*/
constructor(config) {
var _a;
super();
this.page = normalizeTitle(config.page);
this.revid = config.revid;
this.shadow = (_a = config.shadow) !== null && _a !== void 0 ? _a : true;
const userConfig = window.InfringementAssistant.config;
this.data = {
entirePage: userConfig.ia.defaultEntirePage.get(),
fromUrls: userConfig.ia.defaultFromUrls.get()
};
}
/**
* @return The body height of this dialog.
*/
getBodyHeight() {
return 500;
}
/**
* Initializes the dialog.
*/
initialize() {
super.initialize();
const intro = unwrapJQ(h_1("div", { class: "ia-report-intro" }), mw.message('deputy.ia.report.intro', CopyrightProblemsPage.getCurrentListingPage().getPrefixedText()).parseDom());
intro.querySelector('a').setAttribute('target', '_blank');
const page = unwrapJQ(h_1("div", { class: "ia-report-intro" }), mw.message('deputy.ia.report.page', this.page.getPrefixedText()).parseDom());
page.querySelector('a').setAttribute('target', '_blank');
this.fieldsetLayout = new OO.ui.FieldsetLayout({
items: this.renderFields()
});
this.$body.append(new OO.ui.PanelLayout({
expanded: false,
framed: false,
padded: true,
content: [
equalTitle(null, this.page) ? '' : page,
intro,
this.fieldsetLayout,
this.renderSubmitButton()
]
}).$element);
return this;
}
/**
* @return A JSX.Element
*/
renderSubmitButton() {
const hideButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ia.report.hide'),
title: mw.msg('deputy.ia.report.hide'),
flags: ['progressive']
});
hideButton.on('click', () => {
this.executeAction('hide');
});
const submitButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ia.report.submit'),
title: mw.msg('deputy.ia.report.submit'),
flags: ['primary', 'progressive']
});
submitButton.on('click', () => {
this.executeAction('submit');
});
return h_1("div", { class: "ia-report-submit" },
this.shadow && unwrapWidget(hideButton),
unwrapWidget(submitButton));
}
/**
* Render OOUI FieldLayouts to be appended to the fieldset layout.
*
* @return An array of OOUI `FieldLayout`s
*/
renderFields() {
const entirePageByDefault = this.data.entirePage;
this.inputs = {
entirePage: new OO.ui.CheckboxInputWidget({
selected: entirePageByDefault
}),
startSection: new OO.ui.DropdownInputWidget({
$overlay: this.$overlay,
disabled: entirePageByDefault,
title: mw.msg('deputy.ia.report.startSection.placeholder')
}),
endSection: new OO.ui.DropdownInputWidget({
$overlay: this.$overlay,
disabled: entirePageByDefault,
title: mw.msg('deputy.ia.report.endSection.placeholder')
}),
presumptive: new OO.ui.CheckboxInputWidget({
selected: false
}),
presumptiveCase: CCICaseInputWidget({
allowArbitrary: false,
required: true,
showMissing: false,
validateTitle: true,
excludeDynamicNamespaces: true
}),
fromUrls: new OO.ui.CheckboxInputWidget({
selected: this.data.fromUrls
}),
sourceUrls: new OO.ui.MenuTagMultiselectWidget({
$overlay: this.$overlay,
allowArbitrary: true,
inputPosition: 'outline',
indicator: 'required',
placeholder: mw.msg('deputy.ia.report.sourceUrls.placeholder')
}),
sourceText: new OO.ui.MultilineTextInputWidget({
autosize: true,
maxRows: 2,
placeholder: mw.msg('deputy.ia.report.sourceText.placeholder')
}),
additionalNotes: new OO.ui.MultilineTextInputWidget({
autosize: true,
maxRows: 2,
placeholder: mw.msg('deputy.ia.report.additionalNotes.placeholder')
})
};
const fields = {
entirePage: new OO.ui.FieldLayout(this.inputs.entirePage, {
align: 'inline',
label: mw.msg('deputy.ia.report.entirePage.label')
}),
startSection: new OO.ui.FieldLayout(this.inputs.startSection, {
align: 'top',
label: mw.msg('deputy.ia.report.startSection.label')
}),
// Create FieldLayouts for all fields in this.inputs
endSection: new OO.ui.FieldLayout(this.inputs.endSection, {
align: 'top',
label: mw.msg('deputy.ia.report.endSection.label'),
help: mw.msg('deputy.ia.report.endSection.help')
}),
presumptive: new OO.ui.FieldLayout(this.inputs.presumptive, {
align: 'inline',
label: mw.msg('deputy.ia.report.presumptive.label'),
help: mw.msg('deputy.ia.report.presumptive.help')
}),
presumptiveCase: new OO.ui.FieldLayout(this.inputs.presumptiveCase, {
align: 'top',
label: mw.msg('deputy.ia.report.presumptiveCase.label'),
help: mw.msg('deputy.ia.report.presumptiveCase.help')
}),
fromUrls: new OO.ui.FieldLayout(this.inputs.fromUrls, {
align: 'inline',
label: mw.msg('deputy.ia.report.fromUrls.label'),
help: mw.msg('deputy.ia.report.fromUrls.help')
}),
sourceUrls: new OO.ui.FieldLayout(this.inputs.sourceUrls, {
align: 'top',
label: mw.msg('deputy.ia.report.source.label')
}),
sourceText: new OO.ui.FieldLayout(this.inputs.sourceText, {
align: 'top',
label: mw.msg('deputy.ia.report.source.label')
}),
additionalNotes: new OO.ui.FieldLayout(this.inputs.additionalNotes, {
align: 'top',
label: mw.msg('deputy.ia.report.additionalNotes.label')
})
};
this.inputs.entirePage.on('change', (selected) => {
if (selected === undefined) {
// Bad firing.
return;
}
this.data.entirePage = selected;
this.inputs.startSection.setDisabled(selected);
this.inputs.endSection.setDisabled(selected);
});
const entirePageHiddenCheck = () => {
if (this.inputs.startSection.getValue() === '-1' &&
this.inputs.endSection.getValue() === `${this.sections.length - 1}`) {
this.inputs.entirePage.setSelected(true);
}
};
const thisTitle = this.page.getPrefixedDb();
this.inputs.startSection.on('change', (value) => {
const section = value === '-1' ? null : this.sections[+value];
this.data.startSection = section;
this.data.startOffset = section == null ? 0 : section.byteoffset;
// Automatically lock out sections before the start in the end dropdown
for (const item of this.inputs.endSection.dropdownWidget.menu.items) {
if (item.data === '-1') {
item.setDisabled(value !== '-1');
}
else if (this.sections[item.data].fromtitle === thisTitle) {
if (this.sections[item.data].i < +value) {
item.setDisabled(true);
}
else {
item.setDisabled(false);
}
}
}
entirePageHiddenCheck();
});
this.inputs.endSection.on('change', (value) => {
var _a, _b;
const section = value === '-1' ? null : this.sections[+value];
// Ensure sections exist first.
if (this.sections.length > 0) {
this.data.endSection = section;
// Find the section directly after this one, or if null (or last section), use
// the end of the page for it.
this.data.endOffset = section == null ?
this.sections[0].byteoffset :
((_b = (_a = this.sections[section.i + 1]) === null || _a === void 0 ? void 0 : _a.byteoffset) !== null && _b !== void 0 ? _b : this.wikitext.length);
// Automatically lock out sections before the end in the start dropdown
for (const item of this.inputs.startSection.dropdownWidget.menu.items) {
if (item.data === '-1') {
item.setDisabled(value === '-1');
}
else if (this.sections[item.data].fromtitle === thisTitle) {
if (this.sections[item.data].i > +value) {
item.setDisabled(true);
}
else {
item.setDisabled(false);
}
}
}
}
entirePageHiddenCheck();
});
const enablePresumptive = window.InfringementAssistant.wikiConfig.ia.allowPresumptive.get() &&
!!window.InfringementAssistant.wikiConfig.cci.rootPage.get();
fields.presumptive.toggle(enablePresumptive);
fields.presumptiveCase.toggle(false);
this.inputs.presumptive.on('change', (selected) => {
var _a;
this.data.presumptive = selected;
fields.presumptiveCase.toggle(selected);
fields.fromUrls.toggle(!selected);
if (!selected) {
if ((_a = this.data.fromUrls) !== null && _a !== void 0 ? _a : window.InfringementAssistant.config.ia.defaultFromUrls.get()) {
fields.sourceUrls.toggle(true);
// No need to toggle sourceText, assume it is already hidden.
}
else {
fields.sourceText.toggle(true);
// No need to toggle sourceText, assume it is already hidden.
}
}
else {
fields.sourceUrls.toggle(false);
fields.sourceText.toggle(false);
}
});
this.inputs.presumptiveCase.on('change', (text) => {
this.data.presumptiveCase = text.replace(window.InfringementAssistant.wikiConfig.cci.rootPage.get().getPrefixedText(), '');
});
this.inputs.fromUrls.on('change', (selected = this.data.fromUrls) => {
if (selected === undefined) {
// Bad firing.
return;
}
this.data.fromUrls = selected;
fields.sourceUrls.toggle(selected);
fields.sourceText.toggle(!selected);
});
this.inputs.sourceUrls.on('change', (items) => {
this.data.sourceUrls = items.map((item) => item.data);
});
this.inputs.sourceText.on('change', (text) => {
this.data.sourceText = text.replace(/\.\s*$/, '');
});
// Presumptive deletion is default false, so no need to check for its state here.
if (window.InfringementAssistant.config.ia.defaultFromUrls.get()) {
fields.sourceText.toggle(false);
}
else {
fields.sourceUrls.toggle(false);
}
this.inputs.additionalNotes.on('change', (text) => {
this.data.notes = text;
});
return this.shadow ? getObjectValues(fields) : [
fields.presumptive, fields.presumptiveCase,
fields.fromUrls, fields.sourceUrls, fields.sourceText,
fields.additionalNotes
];
}
/**
* Generate options from the section set.
*
* @return An array of DropdownInputWidget options
*/
generateSectionOptions() {
const thisTitle = this.page.getPrefixedDb();
const options = [];
if (this.sections.length > 0) {
this.sections.forEach((section) => {
options.push(Object.assign({ data: section.i, label: mw.message('deputy.ia.report.section', section.number, section.line).text() }, (section.fromtitle !== thisTitle ? {
disabled: true,
title: mw.message('deputy.ia.report.transcludedSection', section.fromtitle).text()
} : {})));
});
}
else {
this.inputs.entirePage.setDisabled(true);
}
return options;
}
/**
* @param data
* @return An OOUI Process
*/
getSetupProcess(data) {
const process = super.getSetupProcess.call(this, data);
process.next(MwApi.action.get(Object.assign(Object.assign({ action: 'parse' }, (this.revid ? { oldid: this.revid } : { page: this.page.getPrefixedText() })), { prop: 'externallinks|sections|wikitext' })).then((res) => {
var _a, _b, _c;
this.externalLinks = (_a = res.parse.externallinks) !== null && _a !== void 0 ? _a : [];
this.sections = (_c = (_b = res.parse.sections) === null || _b === void 0 ? void 0 : _b.map((v, k) => Object.assign(v, { i: k }))) !== null && _c !== void 0 ? _c : [];
this.wikitext = res.parse.wikitext;
if (this.sections.length === 0) {
// No sections. Automatically use full page.
this.data.entirePage = true;
}
const options = [
{
data: '-1',
label: mw.msg('deputy.ia.report.lead'),
selected: true
},
...this.generateSectionOptions()
];
this.inputs.startSection.setOptions(options);
this.inputs.endSection.setOptions(options);
this.inputs.sourceUrls.menu.clearItems();
this.inputs.sourceUrls.addOptions(this.externalLinks.map((v) => ({ data: v, label: v })));
}));
process.next(() => {
blockExit('ia-spwd');
});
return process;
}
/**
* Hides the page content.
*/
hideContent() {
var _a, _b, _c, _d, _e, _f;
return __awaiter(this, void 0, void 0, function* () {
let finalPageContent;
const wikiConfig = (yield window.InfringementAssistant.getWikiConfig()).ia;
const copyvioWikitext = msgEval(wikiConfig.hideTemplate.get(), {
presumptive: this.data.presumptive ? 'true' : '',
presumptiveCase: this.data.presumptiveCase ? 'true' : '',
fromUrls: this.data.fromUrls ? 'true' : '',
sourceUrls: this.data.sourceUrls ? 'true' : '',
sourceText: this.data.sourceText ? 'true' : '',
entirePage: this.data.entirePage ? 'true' : ''
}, this.data.presumptive ?
`[[${window.deputy.wikiConfig.cci.rootPage.get().getPrefixedText()}/${this.data.presumptiveCase}]]` : (this.data.fromUrls ?
(_b = ((_a = this.data.sourceUrls) !== null && _a !== void 0 ? _a : [])[0]) !== null && _b !== void 0 ? _b : '' :
this.data.sourceText), this.data.entirePage ? 'true' : 'false').text();
if (this.data.entirePage) {
finalPageContent = copyvioWikitext + '\n' + this.wikitext;
}
else {
finalPageContent =
this.wikitext.slice(0, this.data.startOffset) +
copyvioWikitext + '\n' +
this.wikitext.slice(this.data.startOffset, this.data.endOffset) +
wikiConfig.hideTemplateBottom.get() + '\n' +
this.wikitext.slice(this.data.endOffset);
}
yield MwApi.action.postWithEditToken(Object.assign(Object.assign({}, changeTag(yield window.InfringementAssistant.getWikiConfig())), { action: 'edit', title: this.page.getPrefixedText(), text: finalPageContent, summary: decorateEditSummary(this.data.entirePage ?
mw.msg(this.data.presumptive ?
'deputy.ia.content.hideAll.pd' :
'deputy.ia.content.hideAll',
// Only ever used if presumptive is set.
...(this.data.presumptive ? [
window.InfringementAssistant.wikiConfig
.cci.rootPage.get().getPrefixedText(),
this.data.presumptiveCase
] : [])) :
mw.msg(this.data.presumptive ?
'deputy.ia.content.hideAll.pd' :
'deputy.ia.content.hide', this.page.getPrefixedText(), (_c = this.data.startSection) === null || _c === void 0 ? void 0 : _c.anchor, (_d = this.data.startSection) === null || _d === void 0 ? void 0 : _d.line, (_e = this.data.endSection) === null || _e === void 0 ? void 0 : _e.anchor, (_f = this.data.endSection) === null || _f === void 0 ? void 0 : _f.line, ...(this.data.presumptive ? [
window.InfringementAssistant.wikiConfig
.cci.rootPage.get().getPrefixedText(),
this.data.presumptiveCase
] : [])), window.InfringementAssistant.config) }));
});
}
/**
* Posts a listing to the current copyright problems listing page.
*/
postListing() {
var _a, _b, _c;
return __awaiter(this, void 0, void 0, function* () {
const sourceUrls = (_a = this.data.sourceUrls) !== null && _a !== void 0 ? _a : [];
const from = this.data.fromUrls ?
sourceUrls
.map((v) => `[${v}]`)
.join(sourceUrls.length > 2 ?
mw.msg('deputy.comma-separator') :
' ') :
this.data.sourceText;
const comments = (from || '').trim().length !== 0 || this.data.presumptive ?
mw.format(mw.msg(this.data.presumptive ?
'deputy.ia.content.listingComment.pd' :
'deputy.ia.content.listingComment', ...(this.data.presumptive ? [
window.InfringementAssistant.wikiConfig
.cci.rootPage.get().getPrefixedText(),
this.data.presumptiveCase
] : [from]), (_b = this.data.notes) !== null && _b !== void 0 ? _b : '')) :
(_c = this.data.notes) !== null && _c !== void 0 ? _c : '';
yield CopyrightProblemsPage.getCurrent()
.postListing(this.page, comments, this.data.presumptive);
});
}
/**
* @param action
* @return An OOUI Process
*/
getActionProcess(action) {
const process = super.getActionProcess.call(this, action);
if (action === 'submit') {
process.next(this.postListing());
}
if (action === 'submit' || action === 'hide') {
if (this.shadow) {
process.next(this.hideContent());
}
process.next(() => {
mw.notify(!this.shadow ?
mw.msg('deputy.ia.report.success.report') :
(action === 'hide' ?
mw.msg('deputy.ia.report.success.hide') :
mw.msg('deputy.ia.report.success')), { type: 'success' });
});
switch (window.InfringementAssistant.config.ia['on' + (action === 'hide' ? 'Hide' : 'Submit')].get()) {
case TripleCompletionAction.Reload:
process.next(() => {
unblockExit('ia-spwd');
window.location.reload();
});
break;
case TripleCompletionAction.Redirect:
process.next(() => {
unblockExit('ia-spwd');
window.location.href = mw.util.getUrl(CopyrightProblemsPage.getCurrent().title.getPrefixedText());
});
break;
}
}
process.next(function () {
unblockExit('ia-spwd');
this.close({ action: action });
}, this);
return process;
}
},
// For dialogs. Remove if not a dialog.
_a.static = Object.assign(Object.assign({}, OO.ui.ProcessDialog.static), { name: 'iaSinglePageWorkflowDialog', title: mw.msg('deputy.ia'), actions: [
{
flags: ['safe', 'close'],
icon: 'close',
label: mw.msg('deputy.ante.close'),
title: mw.msg('deputy.ante.close'),
invisibleLabel: true,
action: 'close'
}
] }),
_a);
}
/**
* Creates a new SinglePageWorkflowDialog.
*
* @param config Configuration to be passed to the element.
* @return A SinglePageWorkflowDialog object
*/
function SinglePageWorkflowDialog (config) {
if (!InternalSinglePageWorkflowDialog) {
initSinglePageWorkflowDialog();
}
return new InternalSinglePageWorkflowDialog(config);
}
/**
* Delinks wikitext. Does not handle templates. Only does dumb delinking (RegExp
* replacement; does not parse and handle link nesting, etc.).
*
* @param string
* @return delinked wikitext
*/
function delink(string) {
return string.replace(cloneRegex$1(/\[\[(.+?)(?:\|.*?)?]]/g), '$1');
}
/**
* Purges a page.
*
* @param title The title of the page to purge
*/
function purge(title) {
return __awaiter(this, void 0, void 0, function* () {
yield MwApi.action.post({
action: 'purge',
titles: normalizeTitle(title).getPrefixedText(),
redirects: true
});
});
}
/**
*
* @param props
* @param props.button
* @return A panel for opening a single page workflow dialog
*/
function NewCopyrightProblemsListingPanel(props) {
const titleSearch = new mw.widgets.TitleInputWidget({
required: true,
showMissing: false,
validateTitle: true,
excludeDynamicNamespaces: true
});
const cancelButton = new OO.ui.ButtonWidget({
classes: ['ia-listing-new--cancel'],
label: mw.msg('deputy.cancel'),
flags: ['destructive']
});
const openButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ia.listing.new.report'),
flags: ['progressive']
});
const field = new OO.ui.ActionFieldLayout(titleSearch, openButton, {
classes: ['ia-listing-new--field'],
align: 'top',
label: mw.msg('deputy.ia.listing.new.page.label')
});
const el = h_1("div", { class: "ia-listing-new" },
unwrapWidget(field),
unwrapWidget(cancelButton));
openButton.on('click', () => {
if (titleSearch.isQueryValid()) {
mw.loader.using(window.InfringementAssistant.static.dependencies, () => __awaiter(this, void 0, void 0, function* () {
props.button.setDisabled(false);
removeElement(el);
const spwd = SinglePageWorkflowDialog({
page: titleSearch.getMWTitle(),
shadow: false
});
yield openWindow(spwd);
}));
}
});
cancelButton.on('click', () => {
props.button.setDisabled(false);
removeElement(el);
});
return el;
}
/**
*
* @param props
* @param props.button
* @return A panel for reporting multiple pages
*/
function NewCopyrightProblemsBatchListingPanel(props) {
blockExit('ia-ncpbl');
const cancelButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.cancel'),
flags: ['destructive']
});
const openButton = new OO.ui.ButtonWidget({
label: mw.msg('deputy.ia.listing.new.report'),
flags: ['progressive', 'primary']
});
const inputs = {
title: new OO.ui.TextInputWidget({
required: true,
placeholder: mw.msg('deputy.ia.listing.new.title.placeholder')
}),
titleMultiselect: new mw.widgets.TitlesMultiselectWidget({
inputPosition: 'outline',
allowArbitrary: false,
required: true,
showMissing: false,
validateTitle: true,
excludeDynamicNamespaces: true
}),
presumptive: new OO.ui.CheckboxInputWidget({
selected: false
}),
presumptiveCase: CCICaseInputWidget({
allowArbitrary: false,
required: true,
showMissing: false,
validateTitle: true,
excludeDynamicNamespaces: true
}),
comments: new OO.ui.TextInputWidget({
placeholder: mw.msg('deputy.ia.listing.new.comments.placeholder')
})
};
const field = {
title: new OO.ui.FieldLayout(inputs.title, {
align: 'top',
label: mw.msg('deputy.ia.listing.new.title.label')
}),
titleSearch: new OO.ui.FieldLayout(inputs.titleMultiselect, {
align: 'top',
label: mw.msg('deputy.ia.listing.new.pages.label')
}),
comments: new OO.ui.FieldLayout(inputs.comments, {
align: 'top',
label: mw.msg('deputy.ia.listing.new.comments.label')
}),
presumptive: new OO.ui.FieldLayout(inputs.presumptive, {
align: 'inline',
label: mw.msg('deputy.ia.listing.new.presumptive.label'),
help: mw.msg('deputy.ia.listing.new.presumptive.help')
}),
presumptiveCase: new OO.ui.FieldLayout(inputs.presumptiveCase, {
align: 'top',
label: mw.msg('deputy.ia.listing.new.presumptiveCase.label'),
help: mw.msg('deputy.ia.listing.new.presumptiveCase.help')
})
};
const getData = (listingPage) => {
return {
wikitext: listingPage.getBatchListingWikitext(inputs.titleMultiselect.items.map((v) => new mw.Title(v.data)), inputs.title.getValue(), inputs.presumptive.getValue() ?
mw.msg('deputy.ia.content.batchListingComment.pd', window.InfringementAssistant.wikiConfig
.cci.rootPage.get().getPrefixedText(), inputs.presumptiveCase.getValue(), inputs.comments.getValue()) :
inputs.comments.getValue()),
summary: mw.msg(inputs.presumptive.getValue() ?
'deputy.ia.content.batchListing.pd' :
'deputy.ia.content.batchListing', listingPage.title.getPrefixedText(), delink(inputs.title.getValue()))
};
};
const currentListingPage = CopyrightProblemsPage.getCurrent();
const previewPanel = h_1("div", { class: "ia-listing--preview", "data-label": mw.msg('deputy.ia.listing.new.preview') });
// TODO: types-mediawiki limitation
const reloadPreview = mw.util.throttle(() => __awaiter(this, void 0, void 0, function* () {
const data = getData(currentListingPage);
yield renderWikitext(data.wikitext, currentListingPage.title.getPrefixedText(), {
pst: true,
summary: data.summary
}).then((renderedWikitext) => {
var _a, _b;
previewPanel.innerHTML = renderedWikitext;
// Infuse collapsibles
(_b = (_a = $(previewPanel).find('.mw-collapsible')).makeCollapsible) === null || _b === void 0 ? void 0 : _b.call(_a);
$(previewPanel).find('.collapsible')
.each((i, e) => {
var _a, _b;
(_b = (_a = $(e)).makeCollapsible) === null || _b === void 0 ? void 0 : _b.call(_a, {
collapsed: e.classList.contains('collapsed')
});
});
// Add in "summary" row.
previewPanel.insertAdjacentElement('afterbegin', h_1("div", { class: "deputy", style: {
fontSize: '0.9em',
borderBottom: '1px solid #c6c6c6',
marginBottom: '0.5em',
paddingBottom: '0.5em'
} },
"Summary: ",
h_1("i", null,
"(",
h_1("span", { class: "mw-content-text", dangerouslySetInnerHTML: renderedWikitext.summary }),
")")));
// Make all anchor links open in a new tab (prevents exit navigation)
previewPanel.querySelectorAll('a')
.forEach((el) => {
if (el.hasAttribute('href')) {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
}
});
});
}), 500);
getObjectValues(inputs).forEach((a) => {
a.on('change', reloadPreview);
});
const el = h_1("div", { class: "ia-batchListing-new" },
unwrapWidget(field.titleSearch),
unwrapWidget(field.title),
unwrapWidget(field.comments),
previewPanel,
h_1("div", { class: "ia-batchListing-new--buttons" },
unwrapWidget(cancelButton),
unwrapWidget(openButton)));
let disabled = false;
const setDisabled = (_disabled = !disabled) => {
cancelButton.setDisabled(_disabled);
openButton.setDisabled(_disabled);
inputs.title.setDisabled(_disabled);
inputs.titleMultiselect.setDisabled(_disabled);
inputs.comments.setDisabled(_disabled);
disabled = _disabled;
};
openButton.on('click', () => __awaiter(this, void 0, void 0, function* () {
setDisabled(true);
yield reloadPreview();
if (inputs.titleMultiselect.items.length > 0 &&
(inputs.title.getValue() || '').trim().length > 0) {
yield currentListingPage.postListings(inputs.titleMultiselect.items.map((v) => new mw.Title(v.data)), inputs.title.getValue(), inputs.comments.getValue()).then(() => __awaiter(this, void 0, void 0, function* () {
yield purge(currentListingPage.title).catch(() => { });
mw.notify(mw.msg('deputy.ia.listing.new.batchListed'), {
type: 'success'
});
unblockExit('ia-ncpbl');
removeElement(el);
props.button.setDisabled(false);
switch (window.InfringementAssistant.config.ia.onBatchSubmit.get()) {
case CompletionAction.Nothing:
break;
default:
window.location.reload();
}
}), (e) => {
mw.notify(mw.msg('deputy.ia.listing.new.batchError', e.message), {
type: 'error'
});
setDisabled(false);
});
}
}));
cancelButton.on('click', () => {
props.button.setDisabled(false);
unblockExit('ia-ncpbl');
removeElement(el);
});
return el;
}
/**
* @return The HTML button set and panel container
*/
function NewCopyrightProblemsListing() {
const root = h_1("div", { class: "deputy ia-listing-newPanel" });
const addListingButton = new OO.ui.ButtonWidget({
icon: 'add',
label: mw.msg('deputy.ia.listing.new'),
flags: 'progressive'
});
const addBatchListingButton = new OO.ui.ButtonWidget({
icon: 'add',
label: mw.msg('deputy.ia.listing.new.batch'),
flags: 'progressive'
});
addListingButton.on('click', () => {
addListingButton.setDisabled(true);
root.appendChild(h_1(NewCopyrightProblemsListingPanel, { button: addListingButton }));
});
addBatchListingButton.on('click', () => {
addBatchListingButton.setDisabled(true);
root.appendChild(h_1(NewCopyrightProblemsBatchListingPanel, { button: addBatchListingButton }));
});
root.appendChild(unwrapWidget(addListingButton));
root.appendChild(unwrapWidget(addBatchListingButton));
return root;
}
/**
* A CopyrightProblemsPage that represents a page that currently exists on a document.
* This document must be a MediaWiki page, and does not accept Parsoid-based documents
* (due to the lack of `mw.config`), used to get canonical data about the current page.
*
* To ensure that only an active document (either a current tab or a document within an
* IFrame) can be used, the constructor only takes in a `Document`.
*
* This class runs on:
* - The main `Wikipedia:Copyright problems` page
* - `Wikipedia:Copyright problems` subpages (which may/may not be date-specific entries)
*/
class CopyrightProblemsSession extends CopyrightProblemsPage {
/**
*
* @param document
*/
constructor(document = window.document) {
const title = new mw.Title(document.defaultView.mw.config.get('wgPageName'));
const revid = +document.defaultView.mw.config.get('wgCurRevisionId');
super(title, revid);
this.listingMap = new Map();
this.document = document;
}
/**
* @param root
* @return all copyright problem listings on the page.
*/
getListings(root = this.document) {
const links = [];
/**
* Avoids collisions by assigning an `i` number when a page appears as a listing twice.
*/
const headingSets = {};
root.querySelectorAll('#mw-content-text .mw-parser-output a:not(.external)').forEach((link) => {
if (this.listingMap.has(link)) {
links.push(link);
return;
}
const listingData = CopyrightProblemsListing.getListing(link) ||
CopyrightProblemsListing.getBasicListing(link);
if (listingData) {
const listingPageTitle = listingData.listingPage.getPrefixedDb();
if (headingSets[listingPageTitle] == null) {
headingSets[listingPageTitle] = {};
}
const id = normalizeTitle(isFullCopyrightProblemsListing(listingData) ?
listingData.id :
listingData.title).getPrefixedDb();
const pageSet = headingSets[listingPageTitle];
if (pageSet[id] != null) {
pageSet[id]++;
}
else {
pageSet[id] = 1;
}
this.listingMap.set(link, new CopyrightProblemsListing(listingData, this.main ? null : this, pageSet[id]));
links.push(link);
}
});
return links.map((link) => this.listingMap.get(link));
}
/**
* Adds an action link to a copyright problem listing.
*
* @param listing
*/
addListingActionLink(listing) {
const baseElement = listing.element.parentElement;
let beforeChild;
for (const child of Array.from(baseElement.children)) {
if (['OL', 'UL', 'DL'].indexOf(child.tagName) !== -1) {
beforeChild = child;
break;
}
}
const link = ListingActionLink(this, listing);
if (beforeChild) {
beforeChild.insertAdjacentElement('beforebegin', link);
}
else {
baseElement.appendChild(link);
}
}
/**
* Adds a panel containing the "new listing" buttons (single and multiple)
* and the panel container (when filing a multiple-page listing) to the proper
* location: either at the end of the copyright problems section or replacing
* the redlink to the blank copyright problems page.
*/
addNewListingsPanel() {
document.querySelectorAll('.mw-headline a, .mw-heading a, a.external, a.redlink').forEach((el) => {
const href = el.getAttribute('href');
const url = new URL(href, window.location.href);
if (equalTitle(url.searchParams.get('title'), CopyrightProblemsPage.getCurrentListingPage()) ||
url.pathname === mw.util.getUrl(CopyrightProblemsPage.getCurrentListingPage().getPrefixedText())) {
if (el.classList.contains('external') || el.classList.contains('redlink')) {
// Keep crawling up and find the parent of this element that is directly
// below the parser root or the current section.
let currentPivot = el;
while (currentPivot != null &&
!currentPivot.classList.contains('mw-parser-output') &&
['A', 'I', 'B', 'SPAN', 'EM', 'STRONG']
.indexOf(currentPivot.tagName) !== -1) {
currentPivot = currentPivot.parentElement;
}
// We're now at the <p> or <div> or whatever.
// Check if it only has one child (the tree that contains this element)
// and if so, replace the links.
if (currentPivot.children.length > 1) {
return;
}
mw.loader.using([
'oojs-ui-core',
'oojs-ui.styles.icons-interactions',
'mediawiki.widgets',
'mediawiki.widgets.TitlesMultiselectWidget'
], () => {
swapElements(currentPivot, NewCopyrightProblemsListing());
});
}
else {
// This is in a heading. Let's place it after the section heading.
const heading = normalizeWikiHeading(el);
if (heading.root.classList.contains('dp-ia-upgraded')) {
return;
}
heading.root.classList.add('dp-ia-upgraded');
mw.loader.using([
'oojs-ui-core',
'oojs-ui.styles.icons-interactions',
'mediawiki.widgets',
'mediawiki.widgets.TitlesMultiselectWidget'
], () => {
heading.root.insertAdjacentElement('afterend', NewCopyrightProblemsListing());
});
}
}
});
}
}
var iaStyles = ".ia-listing-action {display: inline-block;}body.ltr .ia-listing-action {margin-left: 0.5em;}body.ltr .ia-listing-action--bracket:first-child,body.rtl .ia-listing-action--bracket:first-child {margin-right: 0.2em;}body.rtl .ia-listing-action {margin-right: 0.5em;}body.ltr .ia-listing-action--bracket:last-child,body.rtl .ia-listing-action--bracket:last-child {margin-left: 0.2em;}.ia-listing-action--link[disabled] {color: gray;pointer-events: none;}@keyframes ia-newResponse {from { background-color: #ffe29e }to { background-color: rgba( 0, 0, 0, 0 ); }}.ia-newResponse {animation: ia-newResponse 2s ease-out;}.ia-listing-response, .ia-listing-new {max-width: 50em;}.ia-listing-response {margin-top: 0.4em;margin-bottom: 0.4em;}.mw-content-ltr .ia-listing-response, .mw-content-rtl .mw-content-ltr .ia-listing-response {margin-left: 1.6em;margin-right: 0;}.mw-content-rtl .ia-listing-response, .mw-content-ltr .mw-content-rtl .ia-listing-response {margin-left: 0;margin-right: 1.6em;}.ia-listing-response > div {margin-bottom: 8px;}.ia-listing--preview {box-sizing: border-box;background: #f6f6f6;padding: 0.5em 1em;overflow: hidden;}/** \"Preview\" */.ia-listing--preview::before {content: attr(data-label);color: #808080;display: block;margin-bottom: 0.2em;}.ia-listing-response--submit {text-align: right;}/** * NEW LISTINGS */.ia-listing-newPanel {margin-top: 0.5em;}.ia-listing-new {display: flex;align-items: end;margin-top: 0.5em;padding: 1em;}.ia-listing-new--field {flex: 1;}.ia-listing-new--cancel {margin-left: 0.5em;}.ia-batchListing-new {padding: 1em;max-width: 50em;}.ia-batchListing-new--buttons {display: flex;justify-content: end;margin-top: 12px;}.ia-batchListing-new .ia-listing--preview {margin-top: 12px;}/** * REPORTING DIALOG */.ia-report-intro {font-size: 0.8rem;padding-bottom: 12px;border-bottom: 1px solid gray;margin-bottom: 12px;}.ia-report-intro b {display: block;font-size: 1rem;}.ia-report-submit {padding-top: 12px;display: flex;justify-content: flex-end;}/** * COPYVIO PREVIEWS */.copyvio.deputy-show {display: inherit !important;border: 0.2em solid #f88;padding: 1em;}.dp-hiddenVio {display: flex;flex-direction: row;margin: 1em 0;}.dp-hiddenVio-actions {flex: 0;margin-left: 1em;display: flex;flex-direction: column;justify-content: center;}";
/**
*
*/
class HiddenViolationUI {
/**
*
* @param el
*/
constructor(el) {
if (!el.classList.contains('copyvio') && !el.hasAttribute('data-copyvio')) {
throw new Error('Attempted to create HiddenViolationUI on non-copyvio element.');
}
this.vioElement = el;
}
/**
*
*/
attach() {
this.vioElement.insertAdjacentElement('beforebegin', h_1("div", { class: "deputy dp-hiddenVio" },
h_1("div", null, this.renderMessage()),
h_1("div", { class: "dp-hiddenVio-actions" }, this.renderButton())));
this.vioElement.classList.add('deputy-upgraded');
}
/**
* @return A message widget.
*/
renderMessage() {
return unwrapWidget(DeputyMessageWidget({
type: 'warning',
label: mw.msg('deputy.ia.hiddenVio')
}));
}
/**
* @return A button.
*/
renderButton() {
const button = new OO.ui.ToggleButtonWidget({
icon: 'eye',
label: mw.msg('deputy.ia.hiddenVio.show')
});
button.on('change', (shown) => {
button.setLabel(shown ? mw.msg('deputy.ia.hiddenVio.hide') : mw.msg('deputy.ia.hiddenVio.show'));
button.setIcon(shown ? 'eyeClosed' : 'eye');
this.vioElement.appendChild(h_1("div", { style: "clear: both;" }));
this.vioElement.classList.toggle('deputy-show', shown);
});
return unwrapWidget(button);
}
}
/**
*
*/
class InfringementAssistant extends DeputyModule {
constructor() {
super(...arguments);
this.static = InfringementAssistant;
this.CopyrightProblemsPage = CopyrightProblemsPage;
this.SinglePageWorkflowDialog = SinglePageWorkflowDialog;
}
/**
* @inheritDoc
*/
getName() {
return 'ia';
}
/**
* Perform actions that run *before* IA starts (prior to execution). This involves
* adding in necessary UI elements that serve as an entry point to IA.
*/
preInit() {
const _super = Object.create(null, {
preInit: { get: () => super.preInit }
});
return __awaiter(this, void 0, void 0, function* () {
if (!(yield _super.preInit.call(this, deputyIaEnglish))) {
return false;
}
if (this.wikiConfig.ia.rootPage.get() == null) {
// Root page is invalid. Don't run.
return false;
}
mw.hook('ia.preload').fire();
mw.util.addCSS(iaStyles);
if (
// Button not yet appended
document.getElementById('pt-ia') == null &&
// Not virtual namespace
mw.config.get('wgNamespaceNumber') >= 0) {
mw.util.addPortletLink('p-tb', '#',
// Messages used here:
// * deputy.ia
// * deputy.ia.short
// * deputy.ia.acronym
mw.msg({
full: 'deputy.ia',
short: 'deputy.ia.short',
acronym: 'deputy.ia.acronym'
}[this.config.core.portletNames.get()]), 'pt-ia').addEventListener('click', (event) => {
event.preventDefault();
if (!event.currentTarget
.hasAttribute('disabled')) {
this.openWorkflowDialog();
}
});
}
// Autostart
// Query parameter-based autostart disable (i.e. don't start if param exists)
if (!/[?&]ia-autostart(=(0|no|false|off)?(&|$)|$)/.test(window.location.search)) {
return mw.loader.using(InfringementAssistant.dependencies, () => __awaiter(this, void 0, void 0, function* () {
yield this.init();
}));
}
return true;
});
}
/**
*
*/
init() {
return __awaiter(this, void 0, void 0, function* () {
if (CopyrightProblemsPage.isListingPage() &&
['view', 'diff'].indexOf(mw.config.get('wgAction')) !== -1 &&
// Configured
this.wikiConfig.ia.listingWikitextMatch.get() != null &&
this.wikiConfig.ia.responses.get() != null) {
yield DeputyLanguage.loadMomentLocale();
this.session = new CopyrightProblemsSession();
mw.hook('wikipage.content').add((el) => {
if (el[0].classList.contains('ia-upgraded')) {
return;
}
el[0].classList.add('ia-upgraded');
this.session.getListings(el[0]).forEach((listing) => {
this.session.addListingActionLink(listing);
});
this.session.addNewListingsPanel();
});
}
mw.hook('wikipage.content').add(() => {
mw.loader.using([
'oojs-ui-core',
'oojs-ui-widgets',
'oojs-ui.styles.icons-alerts',
'oojs-ui.styles.icons-accessibility'
], () => {
document.querySelectorAll('.copyvio:not(.deputy-upgraded), [data-copyvio]:not(.deputy-upgraded)').forEach((el) => {
new HiddenViolationUI(el).attach();
});
});
});
});
}
/**
*
*/
openWorkflowDialog() {
return __awaiter(this, void 0, void 0, function* () {
return mw.loader.using(InfringementAssistant.dependencies, () => __awaiter(this, void 0, void 0, function* () {
yield DeputyLanguage.loadMomentLocale();
if (!this.dialog) {
yield DeputyLanguage.loadMomentLocale();
this.dialog = SinglePageWorkflowDialog({
page: new mw.Title(mw.config.get('wgPageName')),
revid: mw.config.get('wgCurRevisionId')
});
this.windowManager.addWindows([this.dialog]);
}
yield this.windowManager.openWindow(this.dialog).opened;
}));
});
}
}
InfringementAssistant.dependencies = [
'moment',
'oojs-ui-core',
'oojs-ui-widgets',
'oojs-ui-windows',
'mediawiki.util',
'mediawiki.api',
'mediawiki.Title',
'mediawiki.widgets'
];
/**
* Handles most recent page visits.
*/
/**
*
*/
class Recents {
/**
* Saves the current page to the local list of most recently visited pages.
*/
static save() {
var _a;
const page = normalizeTitle();
if (page.namespace === nsId('special') ||
page.namespace === nsId('media')) {
// Don't save virtual namespaces.
return;
}
const pageName = page.getPrefixedText();
const recentsArray = (_a = JSON.parse(window.localStorage.getItem(Recents.key))) !== null && _a !== void 0 ? _a : [];
if (recentsArray[0] === pageName) {
// Avoid needless operations.
return;
}
while (recentsArray.indexOf(pageName) !== -1) {
recentsArray.splice(recentsArray.indexOf(pageName), 1);
}
if (recentsArray.length > 0) {
recentsArray.pop();
}
recentsArray.splice(0, 0, pageName);
window.localStorage.setItem(Recents.key, JSON.stringify(recentsArray));
}
/**
* @return The most recently visited pages.
*/
static get() {
return JSON.parse(window.localStorage.getItem(Recents.key));
}
}
Recents.key = 'mw-userjs-recents';
/**
* Applies configuration overrides. This takes two objects, A and B.
* A's keys will be respected and will remain unchanged. Object
* values of A that also exist in B will be overwritten with its
* values in B.
*
* @param data
* @param overrides
* @param logger
*/
function applyOverrides(data, overrides, logger) {
if (overrides) {
for (const category of Object.keys(data)) {
if (!overrides[category]) {
continue; // Category does not exist.
}
for (const categoryKey of Object.keys(overrides[category])) {
if (logger) {
logger(`${category}.${categoryKey}`, data[category][categoryKey], overrides[category][categoryKey]);
}
data[category][categoryKey] =
overrides[category][categoryKey];
}
}
for (const category of Object.keys(overrides)) {
if (!data[category]) {
data[category] = overrides[category];
if (logger) {
logger(`${category}`, data[category], overrides[category]);
}
}
}
}
}
/**
* Runs a clean operation. If `option` is false or null, the operation will not be run.
*
* @param obj
* @param option
* @param callback
*/
function onOption(obj, option, callback) {
if (option == null || option === false) {
return;
}
for (const key of Object.keys(obj)) {
if (option === true ||
option === key ||
(Array.isArray(option) && option.indexOf(key) !== -1)) {
const result = callback(obj[key]);
if (result === undefined) {
delete obj[key];
}
else {
obj[key] = result;
}
}
}
}
/**
* Cleans a parameter list. By default, this performs the following:
* - Removes all undefined, null, or empty values
* - Trims all strings
* - Removes newly undefined, null, or empty values
*
* This mutates the original object and also returns it for chaining.
*
* @param obj
* @param _options
* @return The cleaned parameter list.
*/
function cleanParams(obj, _options = {}) {
const defaultOptions = {
trim: true,
filter: true,
removeYes: false,
removeNo: false,
filter2: true
};
const options = Object.assign({}, defaultOptions, _options);
// First clean pass
onOption(obj, options.filter, (v) => !v || v.length === 0 ? undefined : v);
onOption(obj, options.trim, (v) => v.trim ? v.trim() : v);
onOption(obj, options.removeYes, (v) => yesNo(v, false) ? undefined : v);
onOption(obj, options.removeNo, (v) => yesNo(v, true) ? undefined : v);
// Second clean pass
onOption(obj, options.filter, (v) => !v || v.length === 0 ? undefined : v);
return obj;
}
/**
* Iterates over an array and returns an Iterator which checks each element
* of the array sequentially for a given condition (predicated by `condition`)
* and returns another array, containing an element where `true` was returned,
* and every subsequent element where the check returns `false`.
*
* @param arr
* @param condition
* @yield The found sequence
*/
function* pickSequence(arr, condition) {
let currentValues = null;
let shouldReturnValues = false;
for (const val of arr) {
if (condition(val)) {
shouldReturnValues = true;
if (currentValues != null) {
yield currentValues;
}
currentValues = [val];
continue;
}
if (shouldReturnValues) {
currentValues.push(val);
}
}
if (currentValues.length > 0) {
yield currentValues;
}
}
/**
* Unwraps an element into its child elements. This entirely discards
* the parent element.
*
* @param el The element to unwrap.
* @return The unwrapped element.
*/
function unwrapElement (el) {
return Array.from(el.childNodes).map(v => v instanceof HTMLElement ? v :
(v instanceof Text ? v.textContent : undefined)).filter(v => v !== undefined);
}
var util = {
applyOverrides: applyOverrides,
blockExit: blockExit$1,
classMix: classMix,
cleanParams: cleanParams,
cloneRegex: cloneRegex$1,
copyToClipboard: copyToClipboard,
dangerModeConfirm: dangerModeConfirm,
equalTitle: equalTitle,
error: error,
findNextSiblingElement: findNextSiblingElement,
fromObjectEntries: fromObjectEntries,
generateId: generateId,
getObjectValues: getObjectValues,
last: last,
log: log,
matchAll: matchAll,
moveToStart: moveToStart,
organize: organize,
pickSequence: pickSequence,
removeElement: removeElement,
Requester: Requester,
sleep: sleep,
swapElements: swapElements,
unwrapElement: unwrapElement,
unwrapJQ: unwrapJQ,
unwrapWidget: unwrapWidget,
warn: warn,
yesNo: yesNo
};
/**
* Finds a MediaWiki section heading from the current DOM using its title.
*
* @param sectionHeadingName The name of the section to find.
* @param n The `n` of the section. Starts at 1.
* @return The found section heading. `null` if not found.
*/
function findSectionHeading(sectionHeadingName, n = 1) {
let currentN = 1;
const headlines = Array.from(document.querySelectorAll(
// Old style headings
[1, 2, 3, 4, 5, 6].map(v => `h${v} > .mw-headline`).join(',') +
',' +
// New style headings
[1, 2, 3, 4, 5, 6].map(v => `mw-heading > h${v}`).join(',')));
for (const el of headlines) {
if (el instanceof HTMLElement && el.innerText === sectionHeadingName) {
if (currentN >= n) {
return el.parentElement;
}
else {
currentN++;
}
}
}
return null;
}
/**
* Converts a range-like Object into a native Range object.
*
* @param rangeLike The range to convert
* @param rangeLike.startContainer
* @param rangeLike.startOffset
* @param rangeLike.endContainer
* @param rangeLike.endOffset
* @return A {@link Range} object.
*/
function getNativeRange (rangeLike) {
const doc = rangeLike.startContainer.ownerDocument;
const nativeRange = doc.createRange();
nativeRange.setStart(rangeLike.startContainer, rangeLike.startOffset);
nativeRange.setEnd(rangeLike.endContainer, rangeLike.endOffset);
return nativeRange;
}
/**
* From a list of page titles, get which pages exist.
*
* @param pages The pages to search for
* @return An array of pages which exist, ordered by input order.
*/
function getPageExists (pages) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (!Array.isArray(pages)) {
pages = [pages];
}
const pageNames = pages
.map(p => p instanceof mw.Title ? p.getPrefixedText() : p);
const pageRequest = (yield MwApi.action.get({
action: 'query',
titles: pageNames.join('|')
})).query;
const existingPages = [];
if (pageRequest.pages.length > 0) {
const redirects = toRedirectsObject(pageRequest.redirects, pageRequest.normalized);
const pageMap = Object.fromEntries(pageRequest.pages.map((v) => [v.title, !v.missing]));
// Use `pages` to retain client order (assume MW response can be tampered with)
for (const loc of pageNames) {
const actualLocation = (_a = redirects[loc]) !== null && _a !== void 0 ? _a : loc;
if (pageMap[actualLocation]) {
existingPages.push(actualLocation);
}
}
}
return existingPages;
});
}
/**
* Get the content of a revision on-wiki.
*
* @param revision The revision ID of the revision to get the content of
* @param extraOptions Extra options to pass to the request
* @param api The API object to use
* @return A promise resolving to the page content
*/
function getRevisionContent (revision, extraOptions = {}, api = MwApi.action) {
return api.get(Object.assign({ action: 'query', revids: revision, rvprop: 'content', rvslots: 'main', rvlimit: '1' }, extraOptions)).then((data) => {
return Object.assign(data.query.pages[0].revisions[0].slots.main.content, { contentFormat: data.query.pages[0].revisions[0].slots.main.contentformat });
});
}
var wikiUtil = {
decorateEditSummary: decorateEditSummary,
delink: delink,
errorToOO: errorToOO,
findSectionHeading: findSectionHeading,
getApiErrorText: getApiErrorText,
getNativeRange: getNativeRange,
getPageContent: getPageContent,
getPageExists: getPageExists,
getPageTitle: getPageTitle,
getRevisionContent: getRevisionContent,
getRevisionDiffURL: getRevisionDiffURL,
getRevisionURL: getRevisionURL,
getSectionElements: getSectionElements,
getSectionHTML: getSectionHTML,
getSectionId: getSectionId,
guessAuthor: guessAuthor,
isWikiHeading: isWikiHeading,
msgEval: msgEval,
normalizeTitle: normalizeTitle,
normalizeWikiHeading: normalizeWikiHeading,
nsId: nsId,
openWindow: openWindow,
pagelinkToTitle: pagelinkToTitle,
parseDiffUrl: parseDiffUrl,
performHacks: performHacks,
purge: purge,
renderWikitext: renderWikitext,
sectionHeadingN: sectionHeadingN,
toRedirectsObject: toRedirectsObject
};
var deputyAnnouncementsEnglish = {
"deputy.announcement.template.title": "Announcement title",
"deputy.announcement.template.message": "Announcement message",
"deputy.announcement.template.actionButton.label": "Button label",
"deputy.announcement.template.actionButton.title": "Button title"
};
/**
*
* Deputy announcements
*
* This will be loaded on all standalone modules and on main Deputy.
* Be conservative with what you load!
*
*/
class DeputyAnnouncements {
/**
* Initialize announcements.
* @param config
*/
static init(config) {
return __awaiter(this, void 0, void 0, function* () {
yield Promise.all([
DeputyLanguage.load('shared', deputySharedEnglish),
DeputyLanguage.load('announcements', deputyAnnouncementsEnglish)
]);
mw.util.addCSS('#siteNotice .deputy { text-align: left; }');
for (const [id, announcements] of Object.entries(this.knownAnnouncements)) {
if (config.core.seenAnnouncements.get().includes(id)) {
continue;
}
if (announcements.expiry && (announcements.expiry < new Date())) {
// Announcement has expired. Skip it.
continue;
}
this.showAnnouncement(config, id, announcements);
}
});
}
/**
*
* @param config
* @param announcementId
* @param announcement
*/
static showAnnouncement(config, announcementId, announcement) {
mw.loader.using([
'oojs-ui-core',
'oojs-ui.styles.icons-interactions'
], () => {
const messageWidget = DeputyMessageWidget({
classes: ['deputy'],
icon: 'feedback',
// Messages that can be used here:
// * deputy.announcement.<id>.title
title: mw.msg(`deputy.announcement.${announcementId}.title`),
// Messages that can be used here:
// * deputy.announcement.<id>.message
message: mw.msg(`deputy.announcement.${announcementId}.message`),
closable: true,
actions: announcement.actions.map(action => {
var _a;
const button = new OO.ui.ButtonWidget({
// Messages that can be used here:
// * deputy.announcement.<id>.<action id>.message
label: mw.msg(`deputy.announcement.${announcementId}.${action.id}.label`),
// Messages that can be used here:
// * deputy.announcement.<id>.<action id>.title
title: mw.msg(`deputy.announcement.${announcementId}.${action.id}.title`),
flags: (_a = action.flags) !== null && _a !== void 0 ? _a : []
});
button.on('click', action.action);
return button;
})
});
messageWidget.on('close', () => {
config.core.seenAnnouncements.set([...config.core.seenAnnouncements.get(), announcementId]);
config.save();
});
document.getElementById('siteNotice').appendChild(unwrapWidget(messageWidget));
});
}
}
DeputyAnnouncements.knownAnnouncements = {
// No active announcements
// 'announcementId': {
// actions: [
// {
// id: 'actionButton',
// flags: [ 'primary', 'progressive' ],
// action: () => { /* do something */ }
// }
// ]
// }
};
/**
* The main class for Deputy. Entry point for execution.
*
* This class is not exported to avoid circular references and extraneous
* export code in the Rollup bundle (unnecessary for a userscript).
*/
class Deputy {
/**
* Private constructor. To access Deputy, use `window.deputy` or Deputy.instance.
*/
constructor() {
this.DeputyDispatch = Dispatch;
this.DeputyStorage = DeputyStorage;
this.DeputySession = DeputySession;
this.DeputyCommunications = DeputyCommunications;
this.DeputyCase = DeputyCase;
this.DeputyCasePage = DeputyCasePage;
this.models = {
ContributionSurveyRow: ContributionSurveyRow
};
this.util = util;
this.wikiUtil = wikiUtil;
this.modules = {
CopiedTemplateEditor: CopiedTemplateEditor,
InfringementAssistant: InfringementAssistant
};
/**
* This version of Deputy.
*/
this.version = version;
/**
* The current page as an mw.Title.
*/
this.currentPage = new mw.Title(mw.config.get('wgPageName'));
/**
* The current page ID.
*/
this.currentPageId = mw.config.get('wgArticleId');
// Modules
/**
* CopiedTemplateEditor instance.
*/
this.ante = new CopiedTemplateEditor(this);
this.ia = new InfringementAssistant(this);
/* ignored */
}
/**
* @return An OOUI window manager
*/
get windowManager() {
if (!this._windowManager) {
this._windowManager = new OO.ui.WindowManager();
document.body.appendChild(unwrapWidget(this._windowManager));
}
return this._windowManager;
}
/**
* Initialize Deputy. This static function attaches Deputy to the `window.deputy`
* object and initializes that instance.
*/
static init() {
return __awaiter(this, void 0, void 0, function* () {
Deputy.instance = new Deputy();
window.deputy = Deputy.instance;
return window.deputy.init();
});
}
/**
* Initializes Deputy. By this point, the loader should have succeeded in loading
* all dependencies required for Deputy to work. It's only a matter of initializing
* sub-components as well.
*/
init() {
return __awaiter(this, void 0, void 0, function* () {
// Attach modules to respective names
window.CopiedTemplateEditor = this.ante;
window.InfringementAssistant = this.ia;
mw.hook('deputy.preload').fire(this);
// Initialize the configuration
this.config = UserConfiguration.load();
window.deputyLang = this.config.core.language.get();
// Inject CSS
mw.util.addCSS(deputyStyles);
// Load strings
yield Promise.all([
DeputyLanguage.load('core', deputyCoreEnglish),
DeputyLanguage.load('shared', deputySharedEnglish),
DeputyLanguage.loadMomentLocale()
]);
mw.hook('deputy.i18nDone').fire(this);
yield attachConfigurationDialogPortletLink();
// Initialize the storage.
this.storage = new DeputyStorage();
yield this.storage.init();
// Initialize the Deputy API interface
this.dispatch = Dispatch.i;
// Initialize communications
this.comms = new DeputyCommunications();
this.comms.init();
// Initialize session
this.session = new DeputySession();
if (this.config.core.modules.get().indexOf('cci') !== -1) {
yield this.session.init();
}
// Load modules
if (this.config.core.modules.get().indexOf('ante') !== -1) {
yield this.ante.preInit();
}
if (this.config.core.modules.get().indexOf('ia') !== -1) {
yield this.ia.preInit();
}
yield this.wikiConfig.prepareEditBanners();
log('Loaded!');
mw.hook('deputy.load').fire(this);
// Perform post-load tasks.
yield Promise.all([
// Show announcements (if any)
yield DeputyAnnouncements.init(this.config),
// Asynchronously reload wiki configuration.
this.wikiConfig.update().catch(() => { })
]);
});
}
/**
* Gets the wiki-specific configuration for Deputy.
*
* @return A promise resolving to the loaded configuration
*/
getWikiConfig() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
return (_a = this.wikiConfig) !== null && _a !== void 0 ? _a : (this.wikiConfig = yield WikiConfiguration.load());
});
}
}
mw.loader.using([
'mediawiki.api',
'mediawiki.jqueryMsg',
'mediawiki.Title',
'mediawiki.util',
'moment',
'oojs'
], function () {
Recents.save();
performHacks();
Deputy.init();
});
})();
// </nowiki>
// <3