跳转到内容

MediaWiki:Gadget-AdvancedSiteNotices.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ 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>