跳转到内容

User:SuperGrey/gadgets/CiteUnseen/main.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// Main page: [[User:SuperGrey/gadgets/CiteUnseen]]

var CiteUnseen = {
    /**
     * 解析 COinS 字串,並將其轉換為 object。
     * @param query {String} - COinS 字串
     * @returns {Object} - 解析後的 object
     */
    parseCoinsString: function (query) {
        const result = {};
        // Split the string by '&'
        const pairs = query.split('&');
        pairs.forEach(pair => {
            if (!pair) return;
            // Split at the first '='
            const index = pair.indexOf('=');
            let key, value;
            if (index === -1) {
                key = pair;
                value = "";
            } else {
                key = pair.substring(0, index);
                value = pair.substring(index + 1);
            }
            // Replace '+' with space and decode both key and value
            key = key.replace(/\+/g, ' ');
            value = value.replace(/\+/g, ' ');
            if (!result[key]) {
                result[key] = value;
            } else {
                if (typeof result[key] === 'string') {
                    result[key] = [result[key]];
                }
                result[key].push(value);
            }
        });
        return result;
    },

    /**
     * 解析包含逗號分隔條件的日期規則字串,
     * 每個條件包含一個運算符和一個日期。
     * 返回一個接受日期(或日期字串)的判斷函式,
     * 若日期符合所有條件則返回 true。
     * @param ruleString {String} - 日期規則字串,例如 "<2022-01-01,>2020-01-01"
     * @returns {Function|null} - 判斷函式,若有任何條件無效則返回 null。
     */
    parseDateRule: function (ruleString) {
        ruleString = ruleString.trim();
        if (ruleString.length === 0) {
            return null;
        }

        // Split the rule string by comma into individual conditions.
        let conditionStrings = ruleString.split(',').map(s => s.trim());

        // Array to hold predicate functions for each condition.
        let predicates = [];

        for (let cond of conditionStrings) {
            if (cond.length === 0) continue; // Skip empty conditions

            // Check if the condition starts with an operator (<, >, or =). Default to "=".
            let operator = '=';
            if (cond[0] === '<' || cond[0] === '>' || cond[0] === '=') {
                operator = cond[0];
                cond = cond.substring(1).trim();
            }

            // Parse the date portion.
            let targetDate = new Date(cond);
            if (isNaN(targetDate.getTime())) {
                // If any condition has an invalid date, return null.
                return null;
            }

            // Create and store a predicate function for this condition.
            let predicate;
            switch (operator) {
                case '<':
                    predicate = function (date) {
                        return date < targetDate;
                    };
                    break;
                case '>':
                    predicate = function (date) {
                        return date > targetDate;
                    };
                    break;
                default: // '='
                    predicate = function (date) {
                        return date.getTime() === targetDate.getTime();
                    };
            }
            predicates.push(predicate);
        }

        // Return a predicate function that applies all conditions.
        return function (inputDate) {
            let date = (inputDate instanceof Date) ? inputDate : new Date(inputDate);
            if (isNaN(date.getTime())) {
                return false;
            }
            return predicates.every(fn => fn(date));
        };
    },

    /**
     * 將字串中的特殊字符轉義。
     * @param string {String} - 字串
     * @returns {String} - 轉義後的字串
     */
    escapeRegex: function (string) {
        return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
    },

    /**
     * 建立正規表達式。
     * @param string {String} - 字串
     * @returns {RegExp} - 正規表達式
     */
    urlRegex: function (string) {
        // 給定一個網域和路徑,正規表達式會尋找符合以下規則的子字串:
        //  - 以 http:// 或 https:// 開頭
        //  - 網域前不能有額外的 /
        //  - 網域緊接在 :// 之後,或前面有 .(以考慮子網域)
        //  - 網域和路徑之後,符合以下其中一項:
        //     - 字串結束
        //     - 下一個字元是 /
        //     - 網域以句點結尾(允許像 gov.uk 這樣的頂級域名)
        return new RegExp('https?:\\/\\/([^\\/]*\\.)?' + CiteUnseen.escapeRegex(string) + '($|((?<=\\.)|\\/))');
    },

    /**
     * 檢查來源的作者是否符合規則。
     * @param coins {Object} - COinS object
     * @param rule {Object} - 規則
     * @returns {boolean} - 是否符合規則
     */
    matchAuthor: function (coins, rule) {
        let author = coins['rft.au'];  // can be a string or an array
        if (coins['rft.aulast']) {
            let appendedAuthors = coins['rft.aulast'];
            if (typeof appendedAuthors === 'string' && coins['rft.aufirst']) {
                appendedAuthors = [coins['rft.aufirst'] + ' ' + appendedAuthors];
            } else if (coins['rft.aufirst']) {
                for (let i = 0; i < coins['rft.aufirst'].length; i++) {
                    appendedAuthors[i] = coins['rft.aufirst'][i] + ' ' + appendedAuthors[i];
                }
            }
            if (typeof author === 'string') {
                author = [author];
            }
            author.concat(appendedAuthors);
        }
        if (!author || !rule['author']) {
            return false;
        }
        let authorRegex = new RegExp(rule['author'], 'i');
        if (typeof author === 'string') {
            return authorRegex.test(author);
        } else {
            for (let au of author) {
                if (authorRegex.test(au)) {
                    return true;
                }
            }
            return false;
        }
    },

    /**
     * 檢查來源的出版商是否符合規則。
     * @param coins {Object} - COinS object
     * @param rule {Object} - 規則
     * @returns {boolean} - 是否符合規則
     */
    matchPublisher: function (coins, rule) {
        const coinsPub = coins['rft.pub'] || coins['rft.jtitle'] || null;
        if (!coinsPub || !rule['pub']) {
            return false;
        }
        let publisherRegex = new RegExp(rule['pub'], 'i');
        if (typeof coinsPub === 'string') {
            return publisherRegex.test(coinsPub);
        } else {
            for (let publisher of coinsPub) {
                if (publisherRegex.test(publisher)) {
                    return true;
                }
            }
            return false;
        }
    },

    /**
     * 檢查 COinS 物件中的日期是否符合日期規則。
     * @param coins {Object} - COinS 物件。
     * @param rule {Object} - 包含日期規則字串的規則物件。
     * @returns {boolean} - 如果日期符合規則則返回 true,否則返回 false。
     */
    matchDate: function (coins, rule) {
        let predicate = CiteUnseen.parseDateRule(rule['date']);
        if (!predicate) {
            console.log("[Cite Unseen] Invalid date rule: " + rule['date']);
            return false;
        }

        return predicate(coins['rft.date']);
    },

    /**
     * 檢查來源的 URL 是否符合規則。
     * @param coins {Object} - COinS object
     * @param rule {Object} - 規則
     * @returns {boolean} - 是否符合規則
     */
    matchUrl: function (coins, rule) {
        if (!rule['url'] || !coins['rft_id']) {
            return false;
        }
        let urlRegex = CiteUnseen.urlRegex(rule['url']);
        return urlRegex.test(coins['rft_id']);
    },

    /**
     * 檢查來源的 URL 字串是否符合規則。
     * @param coins {Object} - COinS object
     * @param rule {Object} - 規則
     * @returns {boolean} - 是否符合規則
     */
    matchUrlString: function (coins, rule) {
        if (!rule['url_str'] || !coins['rft_id']) {
            return false;
        }
        let urlString = rule['url_str'];
        if (typeof urlString === 'string') {
            return coins['rft_id'].includes(urlString);
        } else {
            for (let str of urlString) {
                if (coins['rft_id'].includes(str)) {
                    return true;
                }
            }
            return false;
        }
    },

    /**
     * 檢查來源是否符合規則。
     * @param coins {Object} - COinS object
     * @param rule {Object} - 規則
     * @returns {boolean} - 是否符合規則
     */
    match: function (coins, rule) {
        if (!rule) {
            console.log("[Cite Unseen] There are empty rules in the ruleset.");
            return false;
        }
        const matchFunctions = {
            'author': CiteUnseen.matchAuthor,
            'pub': CiteUnseen.matchPublisher,
            'date': CiteUnseen.matchDate,
            'url': CiteUnseen.matchUrl,
            'url_str': CiteUnseen.matchUrlString,
        };
        for (let key of Object.keys(rule)) {
            if (!matchFunctions[key]) {
                console.log("[Cite Unseen] Unknown rule:");
                console.log(rule)
                continue;
            }
            if (!matchFunctions[key](coins, rule)) {
                return false;
            }
        }
        return true;
    },

    /**
     * 為引用的來源加上圖示。僅在頁面載入時執行一次。
     */
    addIcons: function () {
        // 將 categorizedRules 過濾為僅包含頁面引用中出現的連結
        // 考慮到我們追蹤的網域數量以及後續使用的正規表達式,這樣可以顯著節省時間
        // 在一篇約有 500 個引用的文章上進行快速測試,速度提升約 5 倍
        let filteredCategorizedRules = {};
        for (let key of Object.keys(CiteUnseen.categorizedRules)) {
            filteredCategorizedRules[key] = [];
            for (let link of CiteUnseen.refLinks) {
                for (let rule of CiteUnseen.categorizedRules[key]) {
                    let domain = rule['url'];
                    if (!(CiteUnseen.citeUnseenDomainIgnore[key] && CiteUnseen.citeUnseenDomainIgnore[key].includes(domain)) && link.includes(domain)) {
                        if (!filteredCategorizedRules[key].includes(rule)) {
                            filteredCategorizedRules[key].push(rule);
                        }
                    }
                }
            }
        }
        let typeCategories = CiteUnseen.citeUnseenCategoryTypes.flatMap(x => x[1]);

        // 來源是否未被標記
        let unknownSet;

        CiteUnseen.refs.forEach(function (ref) {
            // 在 <cite> 標籤前加入圖示區域
            let iconsDiv = document.createElement("div");
            iconsDiv.classList.add("cite-unseen-icons");
            iconsDiv.style.display = 'inline-flex';
            iconsDiv.style.gap = '0 5px';
            iconsDiv.style.paddingRight = '5px';
            iconsDiv.style.verticalAlign = 'middle';
            ref.cite.prepend(iconsDiv);

            if (window.cite_unseen_dashboard) {
                // 提前放入自訂來源分類的圖示
                let iconNode = CiteUnseen.processIcon(iconsDiv, "flag");
                iconNode.style.display = 'none';  // 預設隱藏
                CiteUnseen.customCategoryIcons.push(iconNode);
            }

            // 根據 class 名稱判斷來源類型。
            const classList = ref.cite.classList;
            if (classList.contains("book") || classList.contains("journal") || classList.contains("encyclopaedia") || classList.contains("conference") || classList.contains("thesis") || classList.contains("magazine")) {
                if (CiteUnseen.citeUnseenCategories.books) {
                    CiteUnseen.processIcon(iconsDiv, "books");
                }
            } else if (classList.contains("pressrelease")) {
                if (CiteUnseen.citeUnseenCategories.press) {
                    CiteUnseen.processIcon(iconsDiv, "press");
                }
            } else if (classList.contains("episode") || classList.contains("podcast") || classList.contains("media")) {
                if (CiteUnseen.citeUnseenCategories.tvPrograms) {
                    CiteUnseen.processIcon(iconsDiv, "tvPrograms");
                }
            } else if (ref.coins['rft_id']) {
                unknownSet = true;

                // 來源檢查表
                let checked = false;
                for (let checklistTypeData of CiteUnseen.citeUnseenChecklists) {
                    if (checked) {
                        break;
                    }
                    let checklistType = checklistTypeData[0];
                    for (let checklist of checklistTypeData[1]) {
                        if (checked) {
                            break;
                        }
                        let checklistName = checklist[0], checklistID = checklist[1];
                        for (let rule of filteredCategorizedRules[checklistID]) {
                            if (CiteUnseen.match(ref.coins, rule)) {
                                if (CiteUnseen.citeUnseenCategories[checklistID]) {
                                    CiteUnseen.processIcon(iconsDiv, checklistType, checklistName);
                                    checked = true;
                                    unknownSet = false;
                                    break;
                                }
                                unknownSet = false;
                            }
                        }
                    }
                }

                // 類型
                for (let category of typeCategories) {
                    for (let rule of filteredCategorizedRules[category]) {
                        if (CiteUnseen.match(ref.coins, rule)) {
                            if (CiteUnseen.citeUnseenCategories[category]) {
                                CiteUnseen.processIcon(iconsDiv, category);
                                unknownSet = false;
                                break;
                            }
                            unknownSet = false;
                        }
                    }
                }

                if (CiteUnseen.citeUnseenCategories.unknown && unknownSet) {
                    // 並未被識別的來源,標記為未知。
                    CiteUnseen.processIcon(iconsDiv, "unknown");
                }
            }
        });

        if (window.cite_unseen_dashboard) {
            CiteUnseen.showDashboard();
        }

        // 結束計時
        console.timeEnd('CiteUnseen runtime');
    },

    /**
     * 添加計數。目前無論是否在 reflist 中,都會記錄進去。
     * @param node {Element} - 節點
     * @param type {String} - 類型
     */
    addToCount: function (node, type) {
        CiteUnseen.citeUnseenCategoryData[type].count = CiteUnseen.citeUnseenCategoryData[type].count + 1;
    },

    /**
     * 為節點添加圖示、滑鼠懸浮文字。
     * @param node {Element} - iconsDiv 節點
     * @param type {String} - 類型
     * @param checklist {String|null} - 檢查表
     * @returns {Element} - iconNode 節點
     */
    processIcon: function (node, type, checklist = null) {
        let iconNode = document.createElement("img");
        iconNode.classList.add("skin-invert");
        iconNode.classList.add("cite-unseen-icon-" + type);
        iconNode.setAttribute("src", CiteUnseen.citeUnseenCategoryData[type].icon);
        let message = CiteUnseen.convByVar({
            hant: CiteUnseen.citeUnseenCategoryData[type].hint_hant,
            hans: CiteUnseen.citeUnseenCategoryData[type].hint_hans,
        });
        if (checklist) {
            message = CiteUnseen.convByVar({hant: '來自 ', hans: '来自 '}) + checklist + ':' + message;
        }
        iconNode.setAttribute("alt", message);
        iconNode.setAttribute("title", "[Cite Unseen] " + message);
        CiteUnseen.addToCount(node, type);
        iconNode.style.width = "17px";
        iconNode.style.height = "17px";
        iconNode.style.objectFit = 'contain';
        iconNode.style.cssText += 'max-width: 17px !important;';
        node.appendChild(iconNode);
        if (!CiteUnseen.refCategories[type]) {
            CiteUnseen.refCategories[type] = [];
        }
        CiteUnseen.refCategories[type].push(node.parentNode);
        return iconNode;
    },

    CustomCategoryDialog: function (config) {
        CiteUnseen.CustomCategoryDialog.super.call(this, config);
    },

    /**
     * 建立一個選擇「網址」或「網址字串」的列。
     * 此函式會生成一個下拉選單,允許使用者在「網址」與「網址字串」之間切換,
     * 並根據選擇更新規則物件。
     * @param {Object} rule - 要根據選擇進行更新的規則物件。
     */
    createUrlRow: function (rule) {
        const chineseUrl = CiteUnseen.convByVar({hant: '網址', hans: '网址'});
        const chineseUrlString = CiteUnseen.convByVar({hant: '網址字串', hans: '网址字符串'});
        // Create menu options for URL and URL String using convByVar.
        var optionURL = new OO.ui.MenuOptionWidget({
            data: 'url', label: chineseUrl,
        });
        var optionURLString = new OO.ui.MenuOptionWidget({
            data: 'url_str', label: chineseUrlString,
        });
        // Initialize the dropdown label based on the current rule.
        var initialLabel = rule.hasOwnProperty('url_str') ? chineseUrlString : chineseUrl;
        var dropdown = new OO.ui.DropdownWidget({
            label: initialLabel, menu: {
                items: [optionURL, optionURLString],
            },
        });
        // Update the rule when an option is chosen.
        dropdown.getMenu().on('choose', function (item) {
            var data = item.getData();
            if (data === 'url') {
                if (rule.url_str !== undefined) {
                    rule.url = rule.url_str;
                    delete rule.url_str;
                }
                dropdown.setLabel(chineseUrl);
            } else if (data === 'url_str') {
                if (rule.url !== undefined) {
                    rule.url_str = rule.url;
                    delete rule.url;
                }
                dropdown.setLabel(chineseUrlString);
            }
        });
        // Create the URL text input widget.
        var urlValue = rule.url || rule.url_str || '';
        var urlInput = new OO.ui.TextInputWidget({
            value: urlValue, title: CiteUnseen.convByVar({
                hant: '網址應包含網域名,不需要包含 http(s);網址字串則可以包含任何字元。',
                hans: '网址应包含网域名,不需要包含 http(s);网址字符串则可以包含任何字符。',
            }),
        });
        // Update rule when the text input value changes.
        urlInput.on('change', function (value) {
            if (rule.hasOwnProperty('url')) {
                rule.url = value;
            } else {
                rule.url_str = value;
            }
        });
        var $row = $('<div>').addClass('cite-unseen-dialog-url-row');
        $row.append(dropdown.$element, urlInput.$element);
        return $row;
    },

    /**
     * 建立可選參數的行(例如作者、出版、日期)。
     * 此函式會生成一個包含多個可選參數的區域,每個參數都包含一個勾選框和文字輸入框。
     * 當勾選框被選中時,對應的文字輸入框會啟用,否則會被禁用。
     * @param rule {Object} - 包含參數值的規則物件。
     */
    createOptionalParamsRow: function (rule) {
        var $paramsContainer = $('<div>').addClass('cite-unseen-dialog-optional-params');
        const params = ['author', 'pub', 'date'];
        const chineseParams = {
            'author': CiteUnseen.convByVar({hant: '作者', hans: '作者'}),
            'pub': CiteUnseen.convByVar({hant: '出版', hans: '出版'}),
            'date': CiteUnseen.convByVar({hant: '日期', hans: '日期'}),
        };
        params.forEach(function (param) {
            var paramValue = rule[param] || '';
            var checkbox = new OO.ui.CheckboxInputWidget({
                selected: !!paramValue,
            });
            var textInput = new OO.ui.TextInputWidget({
                value: paramValue,
            });
            textInput.$element.css('display', !!paramValue ? 'inline-block' : 'none');
            textInput.setDisabled(!checkbox.isSelected());
            checkbox.on('change', function (isSelected) {
                textInput.setDisabled(!isSelected);
                textInput.$element.css('display', isSelected ? 'inline-block' : 'none');
                if (!isSelected) {
                    textInput.setValue('');
                    delete rule[param];
                }
            });
            textInput.on('change', function (value) {
                rule[param] = value;
            });
            var $paramRow = $('<div>')
                .addClass('cite-unseen-dialog-param-container')
                .css({'margin-top': '5px'});
            $paramRow.append(checkbox.$element, $('<span>').text(chineseParams[param]), textInput.$element);
            $paramsContainer.append($paramRow);
        });
        return $paramsContainer;
    },

    /**
     * 為一條規則建立完整的元件。
     * 此函式會生成一個包含規則的容器,並附加相關的 UI 元件。
     * @param {Object} rule - 包含規則資料的物件。
     * @param {OO.ui.FieldsetLayout} parentFieldset - 父級的 Fieldset,用於移除規則時更新 UI。
     */
    createRuleWidget: function (rule, parentFieldset) {
        var $container = $('<div>').addClass('cite-unseen-dialog-rule-container');
        var $urlRow = CiteUnseen.createUrlRow(rule);
        var $optionalParams = CiteUnseen.createOptionalParamsRow(rule);
        var ruleWidget = new OO.ui.Widget({$element: $container});
        // Attach rule data directly to the widget.
        ruleWidget.ruleData = rule;
        var removeButton = new OO.ui.ButtonWidget({
            icon: 'trash', flags: ['destructive'],
        });
        removeButton.$element.addClass('cite-unseen-dialog-remove-button');
        removeButton.on('click', function () {
            if (parentFieldset) {
                parentFieldset.removeItems([ruleWidget]); // Properly remove from the fieldset.
            } else {
                ruleWidget.$element.remove();
            }
            CiteUnseen.updateCustomCategoryDialogHeight();
        });
        $container.append($urlRow, $optionalParams, removeButton.$element);
        return ruleWidget;
    },

    /**
     * 生成自訂來源分類對話框的內容。
     * @returns {OO.ui.FieldsetLayout} - 自訂來源分類對話框的內容
     */
    generateCustomCategoryDialogRules: function (initialRules) {
        var fieldset = new OO.ui.FieldsetLayout({
            classes: ['cite-unseen-dialog-rules'],
        });
        if (initialRules) {
            // Create a widget for each rule without updating any global array.
            initialRules.forEach(function (rule) {
                fieldset.addItems([CiteUnseen.createRuleWidget(rule, fieldset)]);
            });
        }
        var addButton = new OO.ui.ButtonWidget({
            label: CiteUnseen.convByVar({
                hant: '新增規則', hans: '新增规则',
            }), icon: 'add',
        });
        var addButtonField = new OO.ui.FieldLayout(addButton, {align: 'center'});
        addButtonField.$element.addClass('cite-unseen-dialog-add-button');
        fieldset.addButtonField = addButtonField;
        addButton.on('click', function () {
            var newRule = {url: ''};
            var newWidget = CiteUnseen.createRuleWidget(newRule, fieldset);
            // Insert new rule widget just before the add button.
            fieldset.removeItems([fieldset.addButtonField]);
            fieldset.addItems([newWidget]);
            fieldset.addItems([fieldset.addButtonField]);
            CiteUnseen.updateCustomCategoryDialogHeight();
        });
        fieldset.addItems([addButtonField]);
        return fieldset;
    },

    /**
     * 更新自訂來源分類對話框的高度。
     */
    updateCustomCategoryDialogHeight: function () {
        if (CiteUnseen.customCategoryDialog) {
            var maxHeight = $(window).height() * 0.5;
            CiteUnseen.customCategoryDialog.panel.$element.css('max-height', maxHeight + 'px');
            CiteUnseen.customCategoryDialog.updateSize();
        }
    },

    /**
     * 初始化自訂來源分類對話框。
     */
    initCustomCategoryDialog: function () {
        if (CiteUnseen.CustomCategoryDialogRules === null) {
            CiteUnseen.CustomCategoryDialogRules = new OO.ui.FieldsetLayout();
        }
        OO.inheritClass(CiteUnseen.CustomCategoryDialog, OO.ui.Dialog);
        CiteUnseen.CustomCategoryDialog.static.name = 'CustomCategoryDialog';

        CiteUnseen.CustomCategoryDialog.prototype.initialize = function () {
            CiteUnseen.CustomCategoryDialog.super.prototype.initialize.apply(this, arguments);

            this.panel = new OO.ui.PanelLayout({
                padded: true, expanded: false,
            });
            this.panel.$element.addClass('cite-unseen-dialog-panel');
            this.content = new OO.ui.FieldsetLayout();
            this.content.$element.addClass('cite-unseen-dialog-content');

            // 標題
            this.titleLabelWidget = new OO.ui.LabelWidget({
                label: CiteUnseen.convByVar({
                    hant: '[Cite Unseen] 添加自訂來源分類規則', hans: '[Cite Unseen] 添加自定义来源分类规则',
                }), classes: ['cite-unseen-dialog-title'],
            });
            this.titleLabelWidget.$element.append($('<span>').text(CiteUnseen.convByVar({
                hant: '(此為臨時生效;若需自訂已有的來源分類,請參見「',
                hans: '(此为临时生效;若需自定义已有的来源分类,请参见「',
            }))
                .css({'font-size': '0.75em', 'font-weight': 'normal'})
                .append($('<a>')
                    .text(CiteUnseen.convByVar({hant: '進階自訂教程', hans: '进阶自定义教程'}))
                    .attr('href', '//zh.wikipedia.org/wiki/User:SuperGrey/gadgets/CiteUnseen'))
                .append('」。)'));

            // 動作按鈕
            this.cancelButton = new OO.ui.ButtonWidget({
                label: CiteUnseen.convByVar({hant: '取消', hans: '取消'}), flags: ['safe', 'close'], action: 'cancel',
            });
            this.saveButton = new OO.ui.ButtonWidget({
                label: CiteUnseen.convByVar({hant: '儲存', hans: '保存'}),
                flags: ['primary', 'progressive'],
                action: 'save',
            });
            this.actionButtons = new OO.ui.ButtonGroupWidget({
                items: [
                    this.cancelButton, this.saveButton,
                ],
            });
            this.actionButtons.$element.addClass('cite-unseen-dialog-action-buttons');

            // 為按鈕添加點擊事件
            this.cancelButton.$element.on('click', function (e) {
                this.close();
            }.bind(this));
            this.saveButton.$element.on('click', async function (e) {
                let response = await CiteUnseen.addCustomCategory();
                if (response) {
                    this.close();
                }
            }.bind(this));

            // 視窗內容
            this.ruleContent = CiteUnseen.generateCustomCategoryDialogRules(CiteUnseen.customCategoryRules);
            this.content.addItems([
                this.titleLabelWidget, this.actionButtons, this.ruleContent,
            ]);

            this.panel.$element.append(this.content.$element);
            this.$body.append(this.panel.$element);

            // Automatically update height on window resize.
            $(window).on('resize.customCategoryDialog', function () {
                CiteUnseen.updateCustomCategoryDialogHeight();
            });
            CiteUnseen.updateCustomCategoryDialogHeight();
        }
        CiteUnseen.CustomCategoryDialog.prototype.getBodyHeight = function () {
            return this.panel.$element.outerHeight(true);
        };
        CiteUnseen.CustomCategoryDialog.prototype.getTearDownProcess = function (data) {
            return CiteUnseen.CustomCategoryDialog.super.prototype.getTearDownProcess.call(this, data);
        };
    },

    /**
     * 添加自訂來源分類規則。
     * @returns {Promise<boolean>} - 返回一個 Promise,表示操作是否成功。
     */
    addCustomCategory: async function () {
        if (!CiteUnseen.customCategoryDialog) {
            console.error("[Cite Unseen] customCategoryDialog is not initialized when addCustomCategory is called.");
            return false;
        }
        var dialog = CiteUnseen.customCategoryDialog;
        var newRules = [];
        dialog.ruleContent.getItems().forEach(function (item) {
            // Exclude the add button field and only collect widgets with ruleData.
            if (item !== dialog.ruleContent.addButtonField && item.ruleData) {
                newRules.push(item.ruleData);
            }
        });
        console.log(newRules);

        for (let i = 0; i < newRules.length; i++) {
            // Ensure the rule has a non-empty URL value.
            var rule = newRules[i];
            var url = rule.url || rule.url_str;
            if (!url || url.trim() === '') {
                mw.notify(CiteUnseen.convByVar({
                    hant: '來源網址無效,請檢查並重新輸入。', hans: '来源网址无效,请检查并重新输入。',
                }), {type: 'error', autoHide: true, title: '[Cite Unseen]'});
                return false;
            }
        }

        CiteUnseen.customCategoryRules = newRules;

        // 根據自訂規則更新來源圖示的 display 狀態。
        let customCategoryCitations = [];
        for (let i = 0; i < CiteUnseen.refs.length; i++) {
            let ref = CiteUnseen.refs[i];
            let coins = ref.coins;
            let iconNode = CiteUnseen.customCategoryIcons[i];
            let matched = false;
            for (let rule of CiteUnseen.customCategoryRules) {
                if (CiteUnseen.match(coins, rule)) {
                    iconNode.style.display = 'inline-block';
                    matched = true;
                    customCategoryCitations.push(ref.cite);
                    break;
                }
            }
            if (!matched) {
                iconNode.style.display = 'none';
            }
        }

        // 高亮顯示來源
        CiteUnseen.highlightCitation('flag', customCategoryCitations);
        CiteUnseen.refCategories['flag'] = customCategoryCitations;

        return true;
    },

    /**
     * 顯示自訂來源分類對話框。
     */
    showCustomCategoryDialog: function () {
        if (CiteUnseen.CustomCategoryDialogRules === null) {
            CiteUnseen.initCustomCategoryDialog();
        }
        CiteUnseen.CustomCategoryDialogRules.clearItems();
        CiteUnseen.customCategoryDialog = new CiteUnseen.CustomCategoryDialog({padded: true, scrollable: true});
        CiteUnseen.CustomCategoryDialogRules.addItems(CiteUnseen.generateCustomCategoryDialogRules());

        if (CiteUnseen.windowManager === null) {
            CiteUnseen.windowManager = new OO.ui.WindowManager({modal: false, classes: ['cite-unseen-non-modal']});
            mw.util.addCSS(`
            .cite-unseen-non-modal {
                position: -webkit-sticky;
                position: sticky;
                bottom: 1em;
                max-width: 960px;
                margin-left: auto;
                margin-right: auto;
                background-color: white;
            }
            .cite-unseen-non-modal.oo-ui-windowManager-size-full {
                width: 100%;
                height: 100%;
                bottom: 0;
            }
            .cite-unseen-non-modal .oo-ui-window {
                border: 1px solid #a2a9b1;
                box-shadow: 0 0 4px 0 rgba( 0, 0, 0, 0.25 );
            }
            .cite-unseen-non-modal.oo-ui-windowManager-size-full .oo-ui-window {
                border: 0;
                box-shadow: unset;
            }
            .cite-unseen-dialog-panel {
                padding-bottom: 0;
            }
            .cite-unseen-dialog-action-buttons {
                position: absolute;
                top: 0;
                right: -5px;
                display: flex;
                gap: 0 10px;
                flex-wrap: wrap;
            } 
            .cite-unseen-dialog-title {
                font-size: 1.12em;
                font-weight: bold;
                margin-bottom: 10px;
                margin-right: 127px;
            }
            .cite-unseen-dialog-content {
                padding-bottom: 16px;
            }
            .cite-unseen-dialog-rule-container {
                position: relative;
                width: 20em;
                padding: 10px;
                background-color: rgb(249, 249, 249);
                border: 1px solid rgb(204, 204, 204);
                flex-grow: 1;
            }
            .cite-unseen-dialog-rules .oo-ui-fieldsetLayout-group {
                display: flex;
                flex-direction: row;
                flex-wrap: wrap;
                gap: 10px;
            }
            .cite-unseen-dialog-url-row {
                display: flex;
                align-items: center;
                margin-right: 55px;
            }
            .cite-unseen-dialog-url-row .oo-ui-dropdownWidget {
                width: auto !important;
                display: inline-block;
                vertical-align: middle;
                margin-right: 0;
            }
            .cite-unseen-dialog-url-row .oo-ui-textInputWidget {
                max-width: 100%;
                display: inline-block;
                vertical-align: middle;
                margin-left: 8px;
            }
            .cite-unseen-dialog-optional-params {
                display: flex;
                align-items: center;
                flex-wrap: wrap;
                flex-direction: row;
                gap: 0 10px;
            }
            .cite-unseen-dialog-param-container {
                display: flex;
                align-items: center;
                flex-grow: 1;
            }
            .cite-unseen-dialog-param-container .oo-ui-textInputWidget {
                width: 10em;
                vertical-align: middle;
                margin-left: 8px;
                flex-grow: 1;
            }
            .cite-unseen-dialog-remove-button {
                display: block;
                position: absolute;
                top: 11px;
                right: 11px;
            }
            .cite-unseen-dialog-add-button {
                margin-top: 0;
            }
            `);
            $('body').append(CiteUnseen.windowManager.$element);
        }
        CiteUnseen.windowManager.addWindows({'customCategoryDialog': CiteUnseen.customCategoryDialog});
        CiteUnseen.windowManager.openWindow('customCategoryDialog');
    },

    /**
     * 添加來源統計結果展板。
     */
    showDashboard: function () {
        if (CiteUnseen.refs.length === 0) {
            // 沒有來源,不顯示展板。
            return;
        }

        if (CiteUnseen.dashboard === null) {
            CiteUnseen.dashboard = {
                div: document.createElement('div'),
                total: document.createElement('div'),
                cats: document.createElement('div'),
                flag: document.createElement('a'),
                custom: false,
            };

            CiteUnseen.dashboard.div.style.border = '1px solid #ccc';
            CiteUnseen.dashboard.div.style.marginBottom = '1em';
            CiteUnseen.dashboard.div.style.borderRadius = '5px';
            CiteUnseen.dashboard.div.style.fontSize = '.8em';
            CiteUnseen.dashboard.div.style.display = 'flex';
            CiteUnseen.dashboard.div.style.gap = '.5em 1em';
            CiteUnseen.dashboard.div.style.flexWrap = 'wrap';
            CiteUnseen.dashboard.div.style.padding = '5px';
            CiteUnseen.dashboard.div.style.justifyContent = 'center';
            CiteUnseen.dashboard.div.style.textAlign = 'center';

            // 標記的來源總數
            CiteUnseen.dashboard.total.innerText = "[Cite Unseen] 共 " + CiteUnseen.refs.length + CiteUnseen.convByVar({
                hant: ' 個來源', hans: ' 个来源',
            });
            CiteUnseen.dashboard.total.style.fontWeight = 'bold';
            CiteUnseen.dashboard.div.appendChild(CiteUnseen.dashboard.total);

            // 各類型來源
            CiteUnseen.dashboard.cats.style.display = 'contents';
            CiteUnseen.dashboard.cats.style.textAlign = 'center';
            CiteUnseen.dashboard.div.appendChild(CiteUnseen.dashboard.cats);

            // 自訂來源分類按鈕
            CiteUnseen.dashboard.flag.innerText = CiteUnseen.convByVar({hant: '自訂', hans: '自定义'});
            CiteUnseen.dashboard.flag.style.cursor = 'pointer';
            const flagIcon = document.createElement('img');
            flagIcon.src = CiteUnseen.citeUnseenCategoryData['flag'].icon;
            flagIcon.style.width = '17px';
            flagIcon.style.height = '17px';
            flagIcon.style.objectFit = 'contain';
            flagIcon.style.cssText += 'max-width: 17px !important;';
            flagIcon.style.marginRight = '5px';
            flagIcon.style.verticalAlign = 'middle';
            flagIcon.style.display = 'inline-block';
            this.dashboard.flag.prepend(flagIcon);

            CiteUnseen.dashboard.flag.onclick = function () {
                CiteUnseen.showCustomCategoryDialog();
            };
            CiteUnseen.dashboard.div.appendChild(CiteUnseen.dashboard.flag);

            mw.util.addCSS(`
            .cite-unseen-highlight {
                background-color: rgba(255, 246, 153, 0.5);
                -webkit-box-decoration-break: clone;
                box-decoration-break: clone;
            }
            `);

            // 在 {{reflist}} 之前插入展板。若無 {{reflist}},則插入在末尾。
            document.querySelector('#mw-content-text .mw-parser-output').insertBefore(CiteUnseen.dashboard.div, CiteUnseen.reflistNode);
        }

        // 清空各類型來源
        CiteUnseen.dashboard.cats.innerHTML = '';

        // 按順序列出各類型來源
        let categoryTypes = CiteUnseen.citeUnseenChecklists.flatMap(x => x[0]);
        categoryTypes = categoryTypes.concat(CiteUnseen.citeUnseenCategoryTypes.flatMap(x => x[1]));
        categoryTypes.push('unknown');
        for (let category of categoryTypes) {
            let categoryData = CiteUnseen.citeUnseenCategoryData[category];
            if (categoryData.count > 0) {
                let countNode = document.createElement('div');
                let countIcon = document.createElement('img');
                countIcon.alt = CiteUnseen.convByVar({hant: categoryData.hint_hant, hans: categoryData.hint_hans});
                countIcon.src = categoryData.icon;
                countIcon.width = '17';
                countIcon.style.maxHeight = '18px';
                let countText = document.createElement('span');
                countText.innerText = categoryData.count + ' ' + CiteUnseen.convByVar({
                    hant: categoryData.label_hant, hans: categoryData.label_hans,
                });
                countText.style.paddingLeft = '5px';
                countText.style.cursor = 'pointer';
                countText.onmouseover = function () {
                    countText.style.textDecoration = 'underline';
                }
                countText.onmouseout = function () {
                    countText.style.textDecoration = 'none';
                }
                countText.onclick = function () {
                    if (CiteUnseen.dashboardHighlighted === category) {
                        CiteUnseen.highlightCitation(null);  // 取消高亮
                    } else {
                        CiteUnseen.highlightCitation(category);
                    }
                }
                countNode.appendChild(countIcon);
                countNode.appendChild(countText);
                CiteUnseen.dashboard.cats.appendChild(countNode);
            }
        }
    },

    /**
     * 高亮顯示來源。
     * @param category - 來源類別
     * @param nodes - 節點列表。預設為 null,則使用來源類別中的節點。
     */
    highlightCitation: function (category, nodes = null) {
        if (CiteUnseen.dashboardHighlighted) {
            // 清除之前的高亮
            CiteUnseen.refCategories[CiteUnseen.dashboardHighlighted].forEach(function (node) {
                node.classList.remove('cite-unseen-highlight');
            });
        }
        CiteUnseen.dashboardHighlighted = category;
        if (category) {
            nodes = nodes || CiteUnseen.refCategories[category];  // 若未指定節點,則使用來源類別中的節點
            nodes.forEach(function (node) {
                node.classList.add('cite-unseen-highlight');
            });
        }
    },

    /**
     * (helper function) 過濾 obj.filter === filterValue。
     * @param obj - 要過濾的 object
     * @param filter - 過濾的鍵
     * @param filterValue - 過濾的值
     * @returns {Object} - 過濾後的 object
     */
    filterObjectIncludes: function (obj, filter, filterValue) {
        return Object.keys(obj).reduce((acc, val) => (obj[val][filter] !== filterValue ? acc : {
            ...acc, [val]: obj[val],
        }), {});
    },

    /**
     * (helper function) 過濾 obj.filter !== filterValue。
     * @param obj - 要過濾的 object
     * @param filter - 過濾的鍵
     * @param filterValue - 過濾的值
     * @returns {Object} - 過濾後的 object
     */
    filterObjectExcludes: function (obj, filter, filterValue) {
        return Object.keys(obj).reduce((acc, val) => (obj[val][filter] === filterValue ? acc : {
            ...acc, [val]: obj[val],
        }), {});
    },

    /**
     * 從 User:<username>/CiteUnseen-Rules.js 導入使用者自訂規則。
     */
    importCustomRules: async function () {
        try {
            await mw.loader.getScript('/w/index.php?title=User:' + encodeURIComponent(mw.config.get('wgUserName')) + '/CiteUnseen-Rules.js&ctype=text/javascript&action=raw');
        } catch (err) {
            console.log("[Cite Unseen] Error getting Cite Unseen custom rules: " + err.message);
            return;
        }

        try {
            // 以前用過的 config 名稱
            if (!window.cite_unseen_categories && window.cite_unseen_rules) {
                window.cite_unseen_categories = window.cite_unseen_rules;
            }
            if (!window.cite_unseen_categories && window.cite_unseen_ruleset) {
                window.cite_unseen_categories = window.cite_unseen_ruleset;
            }

            // 獲取使用者的來源分類設定
            if (window.cite_unseen_categories && typeof window.cite_unseen_categories === 'object') {
                for (let key in window.cite_unseen_categories) {
                    if (key in CiteUnseen.citeUnseenCategories) {
                        CiteUnseen.citeUnseenCategories[key] = window.cite_unseen_categories[key];
                    } else if (key in [
                        "blacklisted",
                        "deprecated",
                        "generallyUnreliable",
                        "marginallyReliable",
                        "generallyReliable",
                        "multi",
                    ]) {
                        for (let checklistTypeData of CiteUnseen.citeUnseenChecklists) {
                            if (checklistTypeData[0] === key) {
                                for (let checklist of checklistTypeData[1]) {
                                    CiteUnseen.citeUnseenCategories[checklist] = window.cite_unseen_categories[key];
                                }
                            }
                        }
                    }
                }
            }

            // 獲取使用者的網域忽略列表
            if (window.cite_unseen_domain_ignore && typeof window.cite_unseen_domain_ignore === 'object') {
                for (let key in window.cite_unseen_domain_ignore) {
                    if (window.cite_unseen_domain_ignore[key].length && key in CiteUnseen.citeUnseenDomainIgnore) {
                        CiteUnseen.citeUnseenDomainIgnore[key] = window.cite_unseen_domain_ignore[key];
                    }
                }
            }

            // 獲取使用者的自訂網址列表
            if (window.cite_unseen_additional_domains && typeof window.cite_unseen_additional_domains === 'object') {
                for (let key in window.cite_unseen_additional_domains) {
                    if (window.cite_unseen_additional_domains[key].length && key in CiteUnseen.categorizedRules) {
                        CiteUnseen.categorizedRules[key] = CiteUnseen.categorizedRules[key].concat({
                            'url': window.cite_unseen_additional_domains[key],
                        });
                    }
                }
            }

            // 獲取使用者的自訂網址字串列表
            if (window.cite_unseen_additional_strings && typeof window.cite_unseen_additional_strings === 'object') {
                for (let key in window.cite_unseen_additional_strings) {
                    if (window.cite_unseen_additional_strings[key].length && key in CiteUnseen.categorizedRules) {
                        CiteUnseen.categorizedRules[key] = CiteUnseen.categorizedRules[key].concat({
                            'url_str': window.cite_unseen_additional_strings[key],
                        });
                    }
                }
            }

            // 是否顯示展板。預設顯示。
            if (window.cite_unseen_dashboard === undefined) {
                window.cite_unseen_dashboard = true;
            }
        } catch (err) {
            console.log('[Cite Unseen] Could not read custom rules due to error: ', err);
        }
    },

    /**
     * 導入來源分類資料。
     * @returns {Promise<Record<string, Object[]>>}
     */
    importDependencies: async function () {
        await mw.loader.using('ext.gadget.HanAssist', function (require) {
            const {convByVar} = require('ext.gadget.HanAssist');
            CiteUnseen.convByVar = convByVar;
        });
        await mw.loader.getScript('/w/index.php?title=User:SuperGrey/gadgets/CiteUnseen/sources.js&action=raw&ctype=text/javascript');
        return await CiteUnseenData.getCategorizedRules();
    },

    /**
     * 尋找所有 <cite> 標籤,並將其解析為 COinS object;找到 {{reflist}} 所在之處。
     */
    findCitations: function () {
        // 结构:
        //   <cite class="citation book">...</cite>
        //   <span title="...">...</span>

        // 篩選所有 <cite> 標籤
        for (let citeTag of document.querySelectorAll("cite")) {
            let coinsTag = citeTag.nextElementSibling;
            if (!coinsTag || coinsTag.tagName !== 'SPAN' || !coinsTag.hasAttribute('title')) {
                continue;
            }
            let coinsString = decodeURIComponent(coinsTag.getAttribute('title'));
            let coinsObject = CiteUnseen.parseCoinsString(coinsString);
            if (!coinsObject['rft_id']) {
                let aTag = citeTag.querySelector('a.external')
                if (aTag) {
                    coinsObject['rft_id'] = aTag.getAttribute('href');
                }
            }
            CiteUnseen.refs.push({
                cite: citeTag, coins: coinsObject,
            });
            if (coinsObject['rft_id']) {
                CiteUnseen.refLinks.push(coinsObject['rft_id']);
            }
        }

        // 找到 {{reflist}} 所在之處。如有多個,則取最後一個。若無,則插入在末尾。
        let reflists = document.querySelectorAll('#mw-content-text .mw-parser-output div.reflist');
        if (reflists.length > 0) {
            CiteUnseen.reflistNode = reflists[reflists.length - 1];
        } else {
            CiteUnseen.reflistNode = null;  // 插入在末尾
        }
    },

    /**
     * 程式入口。
     */
    init: function () {
        // 開始計時。(在 addIcons() 結尾結束計時。)
        console.time('CiteUnseen runtime');

        // 導入來源分類資料
        CiteUnseen.importDependencies().then(function (categorizedRules) {
            CiteUnseen.categorizedRules = categorizedRules;
            CiteUnseen.citeUnseenCategories = CiteUnseenData.citeUnseenCategories;
            CiteUnseen.citeUnseenCategoryTypes = CiteUnseenData.citeUnseenCategoryTypes;
            CiteUnseen.citeUnseenChecklists = CiteUnseenData.citeUnseenChecklists;
            CiteUnseen.citeUnseenCategoryData = CiteUnseenData.citeUnseenCategoryData;

            // 補上未完備的參數
            for (let key of Object.keys(CiteUnseen.categorizedRules)) {
                if (CiteUnseen.citeUnseenCategories[key] === undefined) {
                    CiteUnseen.citeUnseenCategories[key] = true;
                }
            }

            // 導入使用者自訂規則
            CiteUnseen.importCustomRules().then(function () {
                // 導入完成後,開始處理來源分類。
                CiteUnseen.findCitations();
                CiteUnseen.addIcons();
            });
        });
    },

    categorizedRules: null,  // 來源分類規則
    citeUnseenCategories: null,  // 預設來源分類開關
    citeUnseenCategoryTypes: null,  // 來源分類的類型
    citeUnseenChecklists: null,  // 來源檢查表按大類區分
    citeUnseenCategoryData: null,  // 使用到的分類資料、圖示和計數
    citeUnseenDomainIgnore: {},  // 使用者自訂排除的網域
    refs: [],  // 所有參考文獻
    refLinks: [],  // 所有來源連結
    refCategories: {},  // 所有來源對應的分類
    reflistNode: null,  // {{reflist}} 所在之處
    convByVar: null,  // 簡繁轉換
    dashboard: null,  // 展板
    dashboardHighlighted: null,  // 當前高亮的來源
    CustomCategoryDialogRules: null,  // 自訂來源分類對話框內容
    customCategoryRules: [],  // 自訂來源分類規則
    customCategoryIcons: [],  // 自訂來源分類圖示
    windowManager: null,  // Window manager for OOUI dialogs
};

CiteUnseen.init();