MediaWiki:Gadget-AdvancedSiteNotices.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// <nowiki>
/*
* ****************************************************************************
* * >>>>> TO GADGET IMPORTERS: READ BEFORE IMPORING <<<<< *
* * Please MAKE SURE the notice page ([[Template:AdvancedSiteNotices/ajax]]) *
* * is properly protected on your wiki. *
* * Albeit efforts are made to handle JavaScript expressions correctly, *
* * they are not exhaustively battle-tested. *
* ****************************************************************************
*
* __________________________________________________________________________
* | |
* | === WARNING: GLOBAL GADGET FILE === |
* | Changes to this page affect many users. |
* | Please discuss changes on the talk page or on [[WP:VPT]] before editing. |
* |__________________________________________________________________________|
*
* Advanced Site Notices
* Allow to custom dynamic site notices
* Maintainer: [[User:PhiLiP]], [[User:Diskdance]], [[User:SunAfterRain]]
*/
Promise.all([
$.ready,
mw.geoIP ? mw.geoIP.getPromise().catch(() => { }) : Promise.resolve(),
]).then(([_, geo]) => {
if (
$("#siteNotice").length < 0 ||
mw.config.get("wgAction") === "edit" ||
mw.config.get("wgAction") === "submit"
) {
return;
}
const { conv } = require("ext.gadget.HanAssist");
let customASNInterval = window.customASNInterval || 15;
const COOKIE_NAME = "dismissASN";
let cookieVal = Number.parseInt(mw.cookie.get(COOKIE_NAME) || "-1", 10);
let revisionId = 0;
let timeoutId = null;
let $asnRoot = $("<div>", { id: "asn-dismissable-notice" });
let $asnBody = $("<div>", {
id: "advancedSiteNotices",
class: "mw-parser-output",
});
let $asnClose = $("<button>", {
title: conv({ hans: "关闭", hant: "關閉" }),
"aria-label": conv({ hans: "关闭", hant: "關閉" }),
class: "asn-close-button",
});
$asnRoot.append($asnBody, $asnClose);
$asnClose.click(() => {
$asnClose.prop("disabled", true);
mw.cookie.set(COOKIE_NAME, revisionId, {
expires: 60 * 60 * 24 * 30,
path: "/",
secure: true,
});
clearTimeout(timeoutId);
$asnRoot.fadeOut(() => {
$asnRoot.remove();
});
});
/**
* @typedef {Object} TokenizeStatus
* @property {string} expression
* @property {RegExp} startTokenRe
* @property {number} index
* @property {((item: any) => void) & { __orig__?: (item: any) => void }} append
*/
/**
* Criteria parser. Only a small subset of JavaScript is supported.
*/
class CriteriaExecutor {
/**
* @param {Record<string, (...args: any[]) => any>} functions
*/
constructor(functions = {}) {
this.functions = functions;
}
/**
* Parse expressions into tokens
*
* @param {string} expression
* @return {any[]}
*/
_tokenizeExpression(expression) {
expression = expression.trim();
if (!expression) {
return [];
}
const result = [];
/** @type {TokenizeStatus} */
const status = {
expression,
startTokenRe: /\|\||&&|\b(?:true|false)\b|[('"!\s]/g,
index: 0,
append(item) {
result.push(item);
},
};
let match = status.startTokenRe.exec(expression);
const skipSpace = () => {
while (/\s/.test(expression[status.index] || "")) {
status.index++;
}
};
while (match) {
const token = match[0];
if (status.startTokenRe.lastIndex - token.length !== status.index) {
if (token === "(") {
// Handle function calls
const parsed = this._handleFunctionCall(status);
if (!parsed) {
break;
}
} else {
break;
}
} else {
let parsed = false;
switch (token) {
case "(":
parsed = this._handleExpressionStatement(status);
break;
case "||":
case "&&":
parsed = this._handleLogicalOperator(status, token);
break;
case "!":
parsed = this._handleUnaryExpression(status, token);
break;
case "'":
case '"':
parsed = this._handleStringLiteral(status, token);
break;
case "true":
case "false":
parsed = this._handleBooleanLiteral(status, token);
break;
}
if (!parsed) {
break;
}
}
skipSpace();
status.startTokenRe.lastIndex = status.index;
match = status.startTokenRe.exec(expression);
}
if (status.index < expression.length - 1) {
throw new SyntaxError("Unexpected token.");
}
return result;
}
/**
* Handle function calls
*
* @param {TokenizeStatus} status
* @return {boolean}
*/
_handleFunctionCall(status) {
const functionName = status.expression
.slice(status.index, status.startTokenRe.lastIndex - 1)
.trim();
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(functionName)) {
return false;
}
const argsStartIndex = status.startTokenRe.lastIndex - 1;
const endIndex = this._findNextBalanceItem(
status.expression,
"(",
")",
argsStartIndex
);
if (endIndex === -1) {
throw new SyntaxError("Unbalanced parentheses in function call");
}
const rawArgs = status.expression
.slice(argsStartIndex + 1, endIndex)
.trim();
const args = !rawArgs.length
? []
: this._balanceSplit(rawArgs, ",", "(", ")");
status.append({
type: "FunctionCall",
functionName,
args: args.map((arg) => this._tokenizeExpression(arg)),
});
status.index = endIndex + 1;
return true;
}
/**
* Handle logical operators
*
* @param {TokenizeStatus} status
* @param {string} token
* @return {boolean}
*/
_handleLogicalOperator(status, token) {
status.append({
type: "LogicalExpression",
operator: token,
});
status.index = status.startTokenRe.lastIndex;
return true;
}
/**
* Handle unary expression
*
* @param {TokenizeStatus} status
* @param {string} token
* @return {boolean}
*/
_handleUnaryExpression(status, token) {
const item = {
type: "UnaryExpression",
operator: token,
argument: undefined,
};
status.append(item);
const origAppend = status.append.__orig__ || status.append;
status.append = function (nextItem) {
if (nextItem.type === "LogicalExpression") {
throw new SyntaxError("Unexpected LogicalExpression");
}
item.argument = nextItem;
status.append = origAppend;
};
status.append.__orig__ = origAppend;
status.index = status.startTokenRe.lastIndex;
return true;
}
/**
* Handle boolean literals
*
* @param {TokenizeStatus} status
* @param {string} token
* @return {boolean}
*/
_handleBooleanLiteral(status, token) {
status.append({
type: "BooleanLiteral",
value: token === "true",
});
status.index = status.startTokenRe.lastIndex;
return true;
}
/**
* Handle expression statements
*
* @param {TokenizeStatus} status
* @return {boolean}
*/
_handleExpressionStatement(status) {
const endIndex = this._findNextBalanceItem(
status.expression,
"(",
")",
status.index
);
if (endIndex === -1) {
throw new SyntaxError("Unbalanced parentheses in expression.");
}
status.append({
type: "ExpressionStatement",
expression: this._tokenizeExpression(
status.expression.slice(status.index + 1, endIndex)
),
});
status.index = endIndex + 1;
return true;
}
/**
* Handle string literals
*
* @param {TokenizeStatus} status
* @param {string} delimiter
* @return {boolean}
*/
_handleStringLiteral(status, delimiter) {
const endIndex = this._findNextTokenWithoutEscape(
status.expression,
delimiter,
status.index + 1
);
if (endIndex === -1) {
throw new SyntaxError("Unterminated string literal.");
}
status.append({
type: "StringLiteral",
value: this._parseString(
status.expression.slice(status.index + 1, endIndex)
),
});
status.index = endIndex + 1;
return true;
}
/**
* Find next balanced item
*
* @param {string} expression
* @param {string} startToken
* @param {string} endToken
* @param {number} currentIndex
* @return {number}
*/
_findNextBalanceItem(expression, startToken, endToken, currentIndex) {
let count = 1;
while (++currentIndex < expression.length) {
if (expression[currentIndex] === startToken) {
count++;
} else if (expression[currentIndex] === endToken) {
count--;
}
if (count === 0) {
return currentIndex;
}
}
return -1;
}
/**
* Split balanced sub-expressions
*
* @param {string} expression
* @param {string} startToken
* @param {string} endToken
* @param {string} splitToken
* @return {string[]}
*/
_balanceSplit(expression, splitToken, startToken, endToken) {
const result = [];
let balance = 0;
let current = "";
for (const char of expression) {
if (char === startToken) {
balance++;
} else if (char === endToken) {
balance--;
}
if (char === splitToken && balance === 0) {
result.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current) {
result.push(current.trim());
}
return result;
}
/**
* Find unescaped tokens
*
* @param {string} expression
* @param {string} token
* @param {number} currentIndex
* @return {number}
*/
_findNextTokenWithoutEscape(expression, token, currentIndex) {
while (currentIndex < expression.length) {
const foundIndex = expression.indexOf(token, currentIndex);
if (foundIndex === -1) {
return -1;
} else if (expression[foundIndex - 1] !== "\\") {
return foundIndex;
}
currentIndex = foundIndex + 1;
}
return -1;
}
/**
* Parse strings and handle escape characters
*
* @param {string} input
* @return {string}
*/
_parseString(input) {
return input.replace(
/\\(n|t|r|b|f|x[0-9A-Fa-f]{2}|u\{[0-9A-Fa-f]+\}|u[0-9A-Fa-f]{4}|.)/g,
(_, esc) => {
switch (esc[0]) {
case "n":
return "\n";
case "t":
return "\t";
case "r":
return "\r";
case "b":
return "\b";
case "f":
return "\f";
case "x":
if (esc === "x") {
throw new SyntaxError("Invalid hexadecimal escape sequence.");
}
return String.fromCharCode(parseInt(esc.slice(1), 16));
case "u":
if (esc === "u") {
throw new SyntaxError("Invalid Unicode escape sequence.");
} else if (esc[1] === "{") {
const codePoint = Number.parseInt(esc.slice(2, -1), 16);
if (codePoint > 0x10ffff) {
throw new SyntaxError(
`Undefined Unicode code-point: \\${esc}.`
);
}
return String.fromCodePoint(codePoint);
}
return String.fromCharCode(Number.parseInt(esc.slice(1), 16));
default:
return esc;
}
}
);
}
/**
* @param {any[]} tokens
*/
_tokensToAst(tokens) {
if (!tokens.length) {
throw new TypeError("Token list is empty.");
}
// 先處理 && 再處理 ||
let logicalItemIndex = tokens.findIndex((t) => t.type === "LogicalExpression" && t.operator === '&&');
if (logicalItemIndex === -1) {
logicalItemIndex = tokens.findIndex((t) => t.type === "LogicalExpression" && t.operator === '||');
}
if (logicalItemIndex === -1) {
if (tokens.length === 1) {
return this._tokenToAst(tokens[0]);
}
throw new SyntaxError(`Unexpected ${tokens[1].type}.`);
} else if (logicalItemIndex === 0) {
throw new SyntaxError(`Unexpected LogicalExpression`);
} else {
const left = this._tokensToAst(tokens.slice(0, logicalItemIndex));
const right = this._tokensToAst(tokens.slice(logicalItemIndex + 1));
return {
type: "LogicalExpression",
operator: tokens[logicalItemIndex].operator,
left,
right,
};
}
}
/**
* @param {any} token
*/
_tokenToAst(token) {
if (!token) {
throw new TypeError('token is undefined or null.');
}
const result = Object.assign({}, token);
if (token.type === "UnaryExpression") {
if (!result.argument) {
throw new SyntaxError("Unexpected UnaryExpression.");
}
result.argument = this._tokenToAst(token.argument);
} else if (token.type === "FunctionCall") {
result.args = token.args.map((argument) =>
this._tokensToAst(argument)
);
} else if (token.type === "ExpressionStatement") {
result.expression = this._tokensToAst(token.expression);
}
return result;
}
/**
* @param {string} expression
*/
toAst(expression) {
const tokenizes = this._tokenizeExpression(expression);
return this._tokensToAst(tokenizes);
}
/**
* @param {string} functionName
* @param {any[]} args
*/
_executeFunction(functionName, args) {
if (!Object.prototype.hasOwnProperty.call(this.functions, functionName)) {
throw new Error(`Function ${functionName} is not allowed.`);
}
return this.functions[functionName](...args);
}
/**
* @param {any} ast
*/
evaluate(ast) {
switch (ast.type) {
case "ExpressionStatement":
return this.evaluate(ast.expression);
case "UnaryExpression":
if (ast.operator === "!") {
return !this.evaluate(ast.argument);
}
throw new Error(`Unknown UnaryExpression operator: ${ast.operator}`);
case "LogicalExpression":
if (ast.operator === "&&" || ast.operator === "||") {
const left = this.evaluate(ast.left);
if (ast.operator === "&&" && !left) {
return false;
} else if (ast.operator === "||" && left) {
return true;
}
return this.evaluate(ast.right);
}
throw new Error(`Unknown LogicalExpression operator: ${ast.operator}`);
case "FunctionCall":
const args = ast.args.map((arg) => this.evaluate(arg));
let returnValue = this._executeFunction(ast.functionName, args);
if (typeof returnValue === 'undefined' || returnValue === null) {
returnValue = false;
} else if (typeof returnValue !== 'string' && typeof returnValue !== 'boolean') {
console.warn(
'[AdvancedSiteNotices]: The return type %s of the function %s() is unsafe and has been forcibly converted to a string.',
typeof returnValue,
ast.functionName
);
returnValue = String(returnValue);
}
return returnValue;
case "StringLiteral":
case "BooleanLiteral":
return ast.value;
default:
throw new Error(`Unknown AST node type: ${ast.type}`);
}
}
}
const functions = {};
// 帶參數
if (geo) {
functions.in_country = (...counties) => counties.includes(geo.country);
functions.in_region = (...regions) => regions.includes(geo.region);
functions.in_city = (...cities) => cities.includes(geo.city);
} else {
functions.in_country = functions.in_region = functions.in_city = (...args) => args.length ? true : false;
}
const configs = mw.config.get(["wgUserGroups", "wgUserLanguage"]);
functions.in_group = (...groups) => groups.some(group => configs.wgUserGroups.includes(group));
functions.in_group_every = (...groups) => groups.every(group => configs.wgUserGroups.includes(group));
functions.in_lang = (...useLangs) => useLangs.includes(configs.wgUserLanguage);
// 不帶參數
// 錯誤示範:
// functions.is_anon = mw.user.isAnon; // 不安全,無法確定 mw.user.isAnon 是否有被修改過可以傳奇怪的值進去
// functions.is_anon = () => mw.user.isAnon(); // 不安全,返回值為 boolean 的,只要無法保證一定返回 boolean,就應該全部強制轉換成 boolean
functions.is_anon = () => !!mw.user.isAnon();
// Support for older MediaWiki releases (typically other wikis)
// mw.user.isTemp and mw.user.isNamed are added in MediaWiki 1.40
functions.is_temp = () => typeof mw.user.isTemp === "function" ? !!mw.user.isTemp() : false;
functions.is_named = () => typeof mw.user.isNamed === "function" ? !!mw.user.isNamed() : !mw.user.isAnon();
const parser = new CriteriaExecutor(functions);
const cache = new WeakMap();
function getCache($element, key) {
const element = $element.get(0);
if (cache.has(element)) {
return cache.get(element)[key];
}
}
function setCache($element, key, value) {
const element = $element.get(0);
if (cache.has(element)) {
cache.get(element)[key] = value;
} else {
cache.set(element, {
[key]: value,
});
}
}
function matchCriteria($noticeItem) {
let cache = getCache($noticeItem, "asn-cache");
if (cache !== undefined) {
return cache;
}
let criteria = $noticeItem.attr("data-asn-criteria");
let result;
if (criteria !== undefined) {
if (criteria === "") {
result = true;
} else {
try {
criteria = decodeURIComponent(criteria.replace(/\+/g, "%20")).trim();
const ast = parser.toAst(criteria);
result = !!parser.evaluate(ast);
} catch (error) {
console.warn(
'[AdvancedSiteNotices]: Fail to parse or evaluate criteria "%s":',
criteria,
error
);
result = false;
}
}
} else {
const testList = [];
if ($noticeItem.hasClass("only_sysop")) {
testList.push(functions.in_group("sysop"));
}
if (
$noticeItem.hasClass("only_logged_in") ||
$noticeItem.hasClass("only_logged") /* deprecated */ ||
$noticeItem.hasClass("is_named")
) {
testList.push(functions.is_named());
}
if (
$noticeItem.hasClass("only_logged_out") ||
$noticeItem.hasClass("only_anon") /* deprecated */
) {
testList.push(!functions.is_named());
}
if ($noticeItem.hasClass("is_temp")) {
testList.push(functions.is_temp());
}
if ($noticeItem.hasClass("is_anon")) {
testList.push(functions.is_anon());
}
if ($noticeItem.hasClass("only_zh_cn")) {
testList.push(functions.in_lang("zh-cn"));
}
if ($noticeItem.hasClass("only_zh_hk")) {
testList.push(functions.in_lang("zh-hk"));
}
if ($noticeItem.hasClass("only_zh_sg")) {
testList.push(functions.in_lang("zh-sg"));
}
if ($noticeItem.hasClass("only_zh_tw")) {
testList.push(functions.in_lang("zh-tw"));
}
result = !testList.length || testList.every((v) => !!v);
}
setCache($noticeItem, "asn-cache", result);
return result;
}
function getNoticeElement($noticeItem) {
let $cache = getCache($noticeItem, "asn-element");
if ($cache !== undefined) {
return $cache;
}
$cache = $("<div>").append($noticeItem.contents().clone());
setCache($noticeItem, "asn-element", $cache);
return $cache;
}
let isSetMinHeightCalled = false;
/**
* Set ASN's height to be the maximum of all entries to prevent massive layout shift when shuffling.
*/
function setMinHeight($noticeList) {
let minHeight = -1;
$noticeList.each((_, nt) => {
let $nt = $(nt);
$asnBody.replaceWith($nt);
minHeight = Math.max(minHeight, $nt.height());
$nt.replaceWith($asnBody);
});
$asnRoot.css("min-height", `${minHeight}px`);
if (!isSetMinHeightCalled) {
isSetMinHeightCalled = true;
window.addEventListener(
"resize",
mw.util.debounce(() => setMinHeight($noticeList), 300)
);
}
}
function loadNotice($noticeList, pos) {
const $noticeItem = $noticeList.eq(pos);
let nextPos = pos + 1;
if (nextPos === $noticeList.length) {
nextPos = 0;
}
const $noticeElement = getNoticeElement($noticeItem);
if ($asnBody.children().length) {
$asnBody.stop().fadeOut(() => {
$asnBody.empty().append($noticeElement);
// animation try /catched to avoid TypeError: (Animation.tweeners[prop]||[]).concat is not a function error being seen in production
try {
$asnBody.fadeIn();
} catch (_) { }
});
} else {
$asnBody.append($noticeElement).fadeIn();
}
if ($noticeList.length > 1) {
timeoutId = setTimeout(() => {
loadNotice($noticeList, nextPos);
}, customASNInterval * 1000);
}
}
function initialNotices($noticeList) {
if (!$asnRoot.length || !$noticeList.length || revisionId === cookieVal) {
return;
}
mw.cookie.set(COOKIE_NAME, null);
$asnRoot.appendTo($("#siteNotice"));
setMinHeight($noticeList);
loadNotice($noticeList, Math.floor(Math.random() * $noticeList.length));
}
new mw.Api({
ajax: {
headers: {
"Api-User-Agent": "w:zh:MediaWiki:Gadget-AdvancedSiteNotices.js",
},
},
})
.parse(new mw.Title("Template:AdvancedSiteNotices/ajax"), {
variant: mw.config.get("wgUserVariant"),
maxage: 3600,
smaxage: 3600,
})
.then((html) => {
let $json = $("ul.sitents", $.parseHTML(html));
let $noticeList = $("li", $json).filter((_, li) => matchCriteria($(li)));
revisionId = $json.data("asn-version");
initialNotices($noticeList);
})
.catch((e) => {
console.error("[AdvancedSiteNotices]: error ", e);
});
});
// </nowiki>