跳转到内容

User:SuperGrey/gadgets/voter/main.js

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

var Voter = {
    /**
     * 頁面名稱
     * @type {string}
     */
    pageName: null,

    /**
     * 頁面標題
     * @type {string}
     */
    sectionTitles: [],

    /**
     * 有效投票模板
     * @type {string[]}
     */
    validVoteTemplates: [],

    /**
     * 無效投票模板
     * @type {string[]}
     */
    invalidVoteTemplates: [],

    /**
     * 視窗管理器
     * @type {OO.ui.WindowManager}
     */
    windowManager: null,

    /**
     * 投票對話框
     * @type {OO.ui.ProcessDialog}
     */
    voteDialog: null,

    /**
     * 程式入口。
     */
    init: function () {
        this.pageName = mw.config.get('wgPageName');
        if (!this.validatePage()) {
            console.log("[Voter] 非目標頁面,停止執行。");
            return;
        }
        console.log(`[Voter] 已載入,當前頁面為 ${this.pageName}。`);

        this.addVoteButtons();
        this.setupDialog();

        this.windowManager = new OO.ui.WindowManager();
        $(document.body).append(this.windowManager.$element);

        this.voteDialog = new this.VoteDialog();
        this.windowManager.addWindows([this.voteDialog]);

        console.log(`[Voter] 已識別可投票事項共 ${this.sectionTitles.length} 項。`);
    },

    /**
     * 初始化對話框類別。
     */
    setupDialog: function () {
        let VoteDialog = this.VoteDialog = function (config) {
            Voter.VoteDialog.super.call(this, config);
        };

        OO.inheritClass(VoteDialog, OO.ui.ProcessDialog);
        VoteDialog.static.name = 'voteDialog';
        VoteDialog.static.title = '投票助手 (Voter v4.0.0)';
        VoteDialog.static.actions = [
            {flags: ['primary', 'progressive'], label: '儲存投票', action: 'save'},
            {flags: 'safe', label: '取消'},
        ];

        VoteDialog.prototype.initialize = function () {
            VoteDialog.super.prototype.initialize.call(this);

            this.panel = new OO.ui.PanelLayout({padded: true, expanded: false});
            this.content = new OO.ui.FieldsetLayout();

            this.entrySelection = new OO.ui.MenuTagMultiselectWidget({options: Voter.sectionTitles});
            this.fieldEntrySelection = new OO.ui.FieldLayout(this.entrySelection, {
                label: '投票條目', align: 'top',
            });

            this.templateSelection = new OO.ui.MenuTagMultiselectWidget({
                options: Voter.validVoteTemplates.concat(Voter.invalidVoteTemplates),
            });
            this.fieldTemplateSelection = new OO.ui.FieldLayout(this.templateSelection, {
                label: '投票模板', align: 'top',
            });

            this.voteMessageInput = new OO.ui.MultilineTextInputWidget({autosize: true, rows: 1, maxRows: 10});
            this.fieldVoteMessageInput = new OO.ui.FieldLayout(this.voteMessageInput, {
                label: '投票理由(可不填;無須簽名)', align: 'top',
            });

            this.content.addItems([
                this.fieldEntrySelection, this.fieldTemplateSelection, this.fieldVoteMessageInput,
            ]);
            this.panel.$element.append(this.content.$element);
            this.$body.append(this.panel.$element);

            this.voteMessageInput.connect(this, {resize: 'onVoteMessageInputResize'});
        };

        VoteDialog.prototype.onVoteMessageInputResize = function () {
            this.updateSize();
        };

        VoteDialog.prototype.getBodyHeight = function () {
            return this.panel.$element.outerHeight(true);
        };

        VoteDialog.prototype.getSetupProcess = function (data) {
            return VoteDialog.super.prototype.getSetupProcess.call(this, data).next(function () {
                this.entrySelection.setValue([
                    Voter.sectionTitles.find(x => x.data === data.sectionID),
                ]);
                this.templateSelection.setValue([Voter.validVoteTemplates[0]?.data]);
            }, this);
        };

        VoteDialog.prototype.getActionProcess = function (action) {
            if (action === 'save') {
                if (this.entrySelection.getValue().length && this.templateSelection.getValue().length) {
                    let dialog = this;
                    return new OO.ui.Process(async function () {
                        console.log("[Voter] 投票:", {
                            entries: dialog.entrySelection.getValue(),
                            templates: dialog.templateSelection.getValue(),
                            reason: dialog.voteMessageInput.getValue(),
                        });
                        await Voter.vote(dialog.entrySelection.getValue(), dialog.templateSelection.getValue(), dialog.voteMessageInput.getValue());
                    });
                }
            }
            return VoteDialog.super.prototype.getActionProcess.call(this, action);
        };

        VoteDialog.prototype.getTeardownProcess = function (data) {
            return VoteDialog.super.prototype.getTeardownProcess.call(this, data).first(() => {
            }, this);
        };
    },

    /**
     * 驗證是否為投票頁面,並設置投票模板。
     * @returns {boolean} 是否為有效的投票頁面
     */
    validatePage: function () {
        let validPages = [
            {
                name: 'Wikipedia:新条目推荐/候选', templates: [
                    {data: '支持', label: '支持'},
                    {data: '反對', label: '反對'},
                    {data: '不合要求', label: '不合要求'},
                    {data: '問題不當', label: '問題不當'},
                ],
            }, {
                name: 'Wikipedia:優良條目評選', templates: [
                    {data: 'yesGA', label: '符合優良條目標準'},
                    {data: 'noGA', label: '不符合優良條目標準'},
                ],
            }, {
                name: 'Wikipedia:典范条目评选', templates: [
                    {data: 'yesFA', label: '符合典範條目標準'},
                    {data: 'noFA', label: '不符合典範條目標準'},
                ],
            }, {
                name: 'Wikipedia:特色列表评选', templates: [
                    {data: 'yesFL', label: '符合特色列表標準'},
                    {data: 'noFL', label: '不符合特色列表標準'},
                ],
            },
        ];

        for (let page of validPages) {
            if (this.pageName === page.name || new RegExp(`^${page.name}/`, 'i').test(this.pageName)) {
                this.validVoteTemplates = page.templates;
                this.invalidVoteTemplates = ['中立', '意見', '建議', '疑問', '同上', '提醒'].map(template => ({
                    data: template,
                    label: template,
                }));
                return true;
            }
        }
        return false;
    },

    /**
     * 為每個投票區段添加投票按鈕。
     */
    addVoteButtons: function () {
        let headingSelector = this.pageName === 'Wikipedia:新条目推荐/候选' ? 'div.mw-heading.mw-heading4' : 'div.mw-heading.mw-heading3';

        $(headingSelector).each((index, element) => {
            let $element = $(element);
            let anchor = this.pageName === 'Wikipedia:新条目推荐/候选' ? $element.nextUntil(headingSelector, 'ul').find('li .anchor').attr('id') : $element.find('h3').attr('id');

            if (anchor) {
                let sectionID = this.getSectionID(index + 1);
                $('<span class="mw-editsection-bracket">|</span> <a onclick="Voter.showVoteDialog(' + sectionID + ')">投票</a>').insertAfter($element.find('span.mw-editsection > a'));

                this.sectionTitles.push({data: sectionID, label: anchor.replace(/_/g, ' ')});
            }
        });
        console.log("[Voter] 已添加投票按鈕:", this.sectionTitles);
    },

    /**
     * 打開投票對話框。
     * @param sectionID {number} 章節編號
     */
    showVoteDialog: function (sectionID) {
        event.preventDefault();
        this.windowManager.openWindow(this.voteDialog, {sectionID});
    },

    /**
     * 取得特定章節編輯編號(支援不同參數位置)。
     * @param childid {number} 章節編號
     * @returns {number} 編輯編號
     */
    getSectionID: function (childid) {
        try {
            let $heading = this.pageName === 'Wikipedia:新条目推荐/候选' ? $('div.mw-heading.mw-heading4').eq(childid - 1) : $('div.mw-heading.mw-heading3').eq(childid - 1);

            let $editlink = $heading.find('span.mw-editsection > a');
            let href = $editlink.attr('href');
            if (!href) throw new Error('No href found');

            let match = href.match(/section=(\\d+)/);
            if (match) return +match[1];

            let parts = href.split('&');
            for (let part of parts) {
                if (part.startsWith('section=')) return +part.split('=')[1].replace(/^T-/, '');
            }
        } catch (e) {
            console.log(`[Voter] Failed to get section ID for child ${childid}`);
            throw e;
        }
        return 0;
    },

    /**
     * 比對標題與文本內容。
     * @param title {string} 標題
     * @returns {string[]} 標題變體
     */
    titleVariants: function (title) {
        let us = title.replace(/ /g, '_');
        let sp = title.replace(/_/g, ' ');
        return [title, us, sp, us.charAt(0).toUpperCase() + us.slice(1), sp.charAt(0).toUpperCase() + sp.slice(1)];
    },

    /**
     * 比對文本與標題變體。
     * @param text {string} 文本內容
     * @param title {string} 標題
     * @returns {boolean} 是否包含標題變體
     */
    textMatchTitleVariants: function (text, title) {
        return this.titleVariants(title).some(variant => text.includes(variant));
    },

    /**
     * 將文字加上縮排。
     * @param text {string} 文字內容
     * @param indent {string} 縮排字串
     * @returns {string} 加上縮排的文字
     */
    addIndent: function (text, indent) {
        return text.replace(/^/gm, indent);
    },

    /**
     * 重新載入頁面。
     * @param entryName {string} 章節名稱
     */
    refreshPage: function (entryName) {
        location.href = mw.util.getUrl(this.pageName + '#' + entryName);  // 先跳轉到投票章節,這樣重載後就不會跳到最上面了
        location.reload();
    },

    /**
     * 單次處理投票寫入並檢查衝突。
     * @param tracePage {string} 追蹤頁面
     * @param destPage {string} 目標頁面
     * @param sectionID {number} 章節編號
     * @param text {string} 投票內容
     * @param summary {string} 編輯摘要
     * @returns {Promise<boolean>} 是否發生衝突
     */
    voteAPI: async function (tracePage, destPage, sectionID, text, summary) {
        let api = new mw.Api({userAgent: 'Voter/4.0.0'});
        let votedPageName = this.sectionTitles.find(x => x.data === sectionID)?.label || `section ${sectionID}`;
        mw.notify(`正在為「${votedPageName}」投出一票⋯⋯`);

        let res = await api.get({
            action: 'query',
            titles: tracePage,
            prop: 'revisions|info',
            rvslots: '*',
            rvprop: 'content',
            rvsection: sectionID,
            indexpageids: 1,
        });

        let page = res.query.pages[res.query.pageids[0]];
        let sectionText = page.revisions[0].slots.main['*'];

        if (sectionText === undefined || sectionText === '') {
            console.log(`[Voter] 無法取得「${votedPageName}」的投票區段內容。區段ID:${sectionID}。API 回傳:`, res);
            mw.notify(`無法取得「${votedPageName}」的投票區段內容,請刷新後重試。`);
            return true;
        }

        if (!this.textMatchTitleVariants(sectionText, votedPageName)) {
            console.log(`[Voter] 在「${votedPageName}」的投票區段中找不到該條目。區段文本:`, sectionText);
            mw.notify(`在該章節找不到名為「${votedPageName}」的提名,請刷新後重試。`);
            return true;
        }

        let mod = {
            action: 'edit',
            title: destPage,
            section: sectionID,
            summary: summary,
            token: mw.user.tokens.get('csrfToken'),
        };

        // 處理內部有小標題的情況(例如獨立的評審章節)。
        let innerHeadings = tracePage === 'Wikipedia:新条目推荐/候选' ? sectionText.match(/=====.+?=====/g) : sectionText.match(/====.+?====/g);
        if (innerHeadings) {
            // 在下一個章節號(即小標題)前面插入投票內容。
            mod.section += 1;
            mod.prependtext = text + '\n';
        } else {
            // 內部沒有標題,則直接附在最後面。
            mod.appendtext = '\n' + text;
        }

        await api.post(mod);
        mw.notify(`「${votedPageName}」已完成投票。`);
        return false;
    },

    /**
     * 投票動作的完整實現。
     * @param voteIDs {string[]} 投票ID
     * @param templates {string[]} 投票模板
     * @param message {string} 投票理由
     * @returns {Promise<boolean>} 是否發生衝突
     */
    vote: async function (voteIDs, templates, message) {
        event.preventDefault();
        let VTReason = templates.map(str => `{{${str}}}`).join(';');
        VTReason += message ? ':' + message : '。';
        VTReason += '--~~' + '~~';

        for (const id of voteIDs) {
            let votedPageName = this.sectionTitles.find(x => x.data === id)?.label || `section ${id}`;
            let indent = '*';
            let destPage = this.pageName;

            if (this.pageName === 'Wikipedia:新条目推荐/候选') {
                indent = '**';
            } else if (this.pageName === 'Wikipedia:優良條目評選') {
                destPage += '/提名區';
            } else if (/^Wikipedia:(典范条目评选|特色列表评选)$/i.test(this.pageName)) {
                destPage += '/提名区';
            }

            let text = this.addIndent(VTReason, indent);
            let summary = `/* ${votedPageName} */ `;
            summary += templates.join('、');
            summary += ' ([[User:SuperGrey/gadgets/voter|Voter]])';

            if (await this.voteAPI(this.pageName, destPage, id, text, summary)) return true;
        }

        // 投票完成,等待1秒鐘後刷新頁面。
        setTimeout(() => this.refreshPage(this.sectionTitles.find(x => x.data === voteIDs[0])?.label), 1000);
        return false;
    },
};

// 等待2秒後執行初始化(避免頁面尚未完全載入)。
setTimeout(() => Voter.init(), 2000);