跳转到内容

User:PexEric/IPE-RedirectHelper.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
(function () {
    'use strict';

    /**
     * A user script to create redirects using a modern Codex-based UI.
     * It overrides the redirect buttons in the InPageEdit toolbox.
     */
    class NewRedirect {
        constructor() {
            this.vueApp = null;
            this.mountPoint = null;
            this.api = null;
        }

        /**
         * Initializes the script, loads dependencies, and sets up hooks.
         */
        async init() {
            await mw.loader.using(['@wikimedia/codex', 'mediawiki.api', 'mediawiki.util']);
            this.api = new mw.Api();
            this.overrideInPageEditButtons();
            this.setupVueApp();
        }

        /**
         * Overrides the click handlers for the InPageEdit redirect buttons.
         */
        overrideInPageEditButtons() {
            mw.hook('InPageEdit.toolbox').add(({ $toolbox }) => {
                const redirectFromBtn = $toolbox.find('#redirectfrom-btn');
                const redirectToBtn = $toolbox.find('#redirectto-btn');

                if (redirectFromBtn.length) {
                    redirectFromBtn.off('click').on('click', (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        this.openRedirectDialog('from');
                    });
                }

                if (redirectToBtn.length) {
                    redirectToBtn.off('click').on('click', (e) => {
                        e.preventDefault();
                        e.stopPropagation();
                        this.openRedirectDialog('to');
                    });
                }
            });
        }

        /**
         * Creates and mounts the Vue application that controls the dialog.
         */
        setupVueApp() {
            this.mountPoint = document.body.appendChild(document.createElement('div'));
            const self = this; // Preserve 'this' context for methods passed to Vue

            const app = Vue.createMwApp({
                data() {
                    return {
                        // Dialog state
                        showDialog: false,
                        showConfirmDialog: false,
                        isSaving: false,
                        
                        // Form fields
                        sourcePage: '',
                        targetPage: '',
                        summary: '',
                        syncTalkPage: false,
                        watchPage: true,

                        // Multiselect for categories
                        categoryChips: [],
                        categorySelection: [],
                        categoryInputValue: '',
                        categoryMenuItems: [],
                        _originalCategoryItems: [], // For resetting filter

                        // Combobox for summary
                        summaryOptions: [
                            { label: '修复双重重定向', value: '修复双重重定向' },
                            { label: '修复循环重定向', value: '修复循环重定向' },
                            { label: '移除误植的重定向分类模板', value: '移除误植的重定向分类模板' }
                        ],
                        
                        // Confirmation dialog content
                        confirmMessage: '',
                        confirmCallback: () => {}
                    };
                },
                computed: {
                    primaryAction() {
                        return {
                            label: this.isSaving ? '保存中...' : '保存',
                            actionType: 'progressive',
                            disabled: this.isSaving
                        };
                    }
                },
                template: `
                    <cdx-dialog
                        v-model:open="showDialog"
                        title="创建重定向"
                        :primary-action="primaryAction"
                        :default-action="{ label: '取消', disabled: isSaving }"
                        @primary="handleSave"
                        @default="showDialog = false"
                    >
                        <cdx-field :status="sourcePage ? 'default' : 'error'" :messages="{ error: '来源页面不能为空' }">
                            <cdx-text-input v-model="sourcePage" placeholder="来源页面"></cdx-text-input>
                            <template #label>来源页面</template>
                        </cdx-field>

                        <cdx-field :status="targetPage ? 'default' : 'error'" :messages="{ error: '目标页面不能为空' }">
                            <cdx-text-input v-model="targetPage" placeholder="目标页面"></cdx-text-input>
                            <template #label>目标页面</template>
                        </cdx-field>

                        <cdx-field>
                            <cdx-multiselect-lookup
                                v-model:input-chips="categoryChips"
                                v-model:selected="categorySelection"
                                v-model:input-value="categoryInputValue"
                                :menu-items="categoryMenuItems"
                                placeholder="添加分类模板..."
                                @input="onCategoryInput"
                            >
                                <template #no-results>无匹配模板。</template>
                            </cdx-multiselect-lookup>
                            <template #label>重定向分类模板</template>
                        </cdx-field>

                        <cdx-field>
                            <cdx-combobox
                                v-model="summary"
                                :menu-items="summaryOptions"
                                placeholder="编辑摘要"
                            >
                            </cdx-combobox>
                            <template #label>编辑摘要</template>
                        </cdx-field>

                        <cdx-field><cdx-checkbox v-model="syncTalkPage">同步讨论页</cdx-checkbox></cdx-field>
                        <cdx-field><cdx-checkbox v-model="watchPage">监视此页面</cdx-checkbox></cdx-field>
                    </cdx-dialog>

                    <cdx-dialog
                        v-model:open="showConfirmDialog"
                        title="确认操作"
                        :primary-action="{ label: '确认', actionType: 'destructive' }"
                        :default-action="{ label: '取消' }"
                        @primary="onConfirm"
                        @default="showConfirmDialog = false"
                    >
                        <p v-html="confirmMessage"></p>
                    </cdx-dialog>
                `,
                methods: {
                    /** Initializes and opens the main dialog. */
                    openDialog(source, target) {
                        // Reset state
                        this.sourcePage = source;
                        this.targetPage = target;
                        this.summary = '';
                        this.categorySelection = [];
                        this.categoryChips = [];
                        this.isSaving = false;
                        this.showDialog = true;
                        this.fetchRedirectTemplates();
                    },

                    /** Handles the primary save button click, initiating validation. */
                    async handleSave() {
                        if (!this.sourcePage || !this.targetPage) {
                            mw.notify('来源和目标页面均不能为空。', { type: 'error' });
                            return;
                        }
                        if (this.sourcePage === this.targetPage) {
                            mw.notify('来源和目标页面不能相同。', { type: 'error' });
                            return;
                        }

                        this.isSaving = true;
                        const [sourceValidation, targetValidation] = await Promise.all([
                            self.validatePage(this.sourcePage),
                            self.validatePage(this.targetPage)
                        ]);
                        this.isSaving = false;

                        let warnings = [];
                        if (targetValidation.isRedirect) {
                            warnings.push(`目标页面 <strong>${this.targetPage}</strong> 本身是一个重定向。`);
                        }
                        if (targetValidation.isDisambiguation) {
                            warnings.push(`目标页面 <strong>${this.targetPage}</strong> 是一个消歧义页。`);
                        }

                        let message = warnings.length > 0 ? warnings.join('<br>') + '<br><br>' : '';

                        if (sourceValidation.exists) {
                            message += `页面 <strong>${this.sourcePage}</strong> 已存在。是否要覆盖它?`;
                            this.showConfirmation(message, () => this.executeSave(true));
                        } else {
                             message += '是否确认创建新重定向?';
                             this.showConfirmation(message, () => this.executeSave(false));
                        }
                    },
                    
                    /** Shows a confirmation dialog. */
                    showConfirmation(message, callback) {
                        this.confirmMessage = message;
                        this.confirmCallback = callback;
                        this.showConfirmDialog = true;
                    },

                    /** Handles the confirmation action. */
                    onConfirm() {
                        this.showConfirmDialog = false;
                        if (typeof this.confirmCallback === 'function') {
                            this.confirmCallback();
                        }
                    },

                    /** Executes the final save/edit operation via API. */
                    async executeSave(isEdit) {
                        this.isSaving = true;
                        const content = self.buildRedirectContent(this.targetPage, this.categorySelection);
                        const finalSummary = (this.summary || `重定向到[[${this.targetPage}]]`) + '(IPE+[[User:PexEric/IPE-RedirectHelper.js|RedirectHelper]])';
                        
                        try {
                            const params = { summary: finalSummary, watchlist: this.watchPage ? 'watch' : 'unwatch' };
                            if (isEdit) {
                                await self.api.edit(this.sourcePage, () => ({ text: content, ...params }));
                            } else {
                                await self.api.create(this.sourcePage, params, content);
                            }

                            mw.notify('重定向操作成功!', { type: 'success' });

                            if (this.syncTalkPage) {
                                await this.syncTalkPageHandler();
                            }

                            // Reload the page to see the result
                            window.location.href = mw.util.getUrl(this.sourcePage, { redirect: 'no' });

                        } catch (error) {
                            mw.notify(`操作失败: ${error?.error?.info || error}`, { type: 'error' });
                        } finally {
                            this.isSaving = false;
                            this.showDialog = false;
                        }
                    },
                    
                    /** Handles synchronizing the talk page redirect. */
                    async syncTalkPageHandler() {
                        const sourceTalkPage = new mw.Title(this.sourcePage).getTalkPage().getPrefixedText();
                        const targetTalkPage = new mw.Title(this.targetPage).getTalkPage().getPrefixedText();
                        const talkContent = `#REDIRECT [[${targetTalkPage}]]`;
                        const talkSummary = '同步主页面的重定向';

                        try {
                            // Use edit with createonmissing flag for simplicity
                            await self.api.postWithToken('csrf', {
                                action: 'edit',
                                title: sourceTalkPage,
                                text: talkContent,
                                summary: talkSummary,
                                createonly: false, // allows editing existing page
                                nocreate: false // allows creating new page
                            });
                             mw.notify('讨论页同步成功!', { type: 'success' });
                        } catch (error) {
                             mw.notify(`同步讨论页时出错: ${error?.error?.info || error}`, { type: 'error' });
                        }
                    },

                    /** Fetches redirect category templates from on-wiki JSON. */
                    async fetchRedirectTemplates() {
                        try {
                            const response = await self.api.get({
                                action: "query",
                                formatversion: "2",
                                prop: "revisions",
                                rvprop: "content",
                                rvslots: "main",
                                titles: "User:SuperGrey/gadgets/RedirectHelper/categories.json",
                            });
                            const content = response.query.pages[0]?.revisions?.[0]?.slots?.main?.content;
                            const templateData = JSON.parse(content || '{}');
                            const items = Object.keys(templateData).map(key => ({ label: key, value: key }));
                            this.categoryMenuItems = items;
                            this._originalCategoryItems = items;
                        } catch (e) {
                            console.error('Failed to fetch redirect templates:', e);
                            this.categoryMenuItems = [];
                            this._originalCategoryItems = [];
                        }
                    },

                    /** Filters category suggestions based on user input. */
                    onCategoryInput(value) {
                        if (!value) {
                            this.categoryMenuItems = this._originalCategoryItems;
                            return;
                        }
                        const lowerCaseValue = value.toLowerCase();
                        this.categoryMenuItems = this._originalCategoryItems.filter(
                            (item) => item.label.toLowerCase().includes(lowerCaseValue)
                        );
                    }
                }
            });

            // Register Codex components
            const Codex = mw.loader.require('@wikimedia/codex');
            app.component('CdxDialog', Codex.CdxDialog)
               .component('CdxButton', Codex.CdxButton)
               .component('CdxTextInput', Codex.CdxTextInput)
               .component('CdxField', Codex.CdxField)
               .component('CdxCheckbox', Codex.CdxCheckbox)
               .component('CdxCombobox', Codex.CdxCombobox)
               .component('CdxMultiselectLookup', Codex.CdxMultiselectLookup);

            this.vueApp = app.mount(this.mountPoint);
        }

        /** Opens the redirect dialog and pre-fills form data. */
        openRedirectDialog(direction) {
            if (this.vueApp) {
                const currentPage = mw.config.get('wgPageName');
                const source = direction === 'to' ? currentPage : '';
                const target = direction === 'from' ? currentPage : '';
                this.vueApp.openDialog(source, target);
            }
        }
        
        /**
         * Validates a page title to check for existence, redirects, etc.
         * @param {string} title
         * @returns {Promise<object>}
         */
        async validatePage(title) {
            try {
                const response = await this.api.get({
                    action: 'query',
                    formatversion: '2',
                    prop: 'info|pageprops',
                    titles: title
                });
                const page = response.query.pages[0];
                return {
                    exists: !page.missing,
                    isRedirect: !!page.redirect,
                    isDisambiguation: !!page.pageprops?.disambiguation,
                };
            } catch (e) {
                return { exists: false, isRedirect: false, isDisambiguation: false };
            }
        }

        /**
         * Builds the wikitext for the redirect page.
         * @param {string} target
         * @param {string[]} categories
         * @returns {string}
         */
        buildRedirectContent(target, categories) {
            let content = `#REDIRECT [[${target}]]\n`;
            if (categories && categories.length > 0) {
                const templates = categories.map(cat => `{{${cat}}}`).join('\n');
                content += `\n{{Redirect category shell|\n${templates}\n}}`;
            }
            return content;
        }
    }

    // Initialize the script after the InPageEdit hook is available.
    mw.hook('InPageEdit').add(() => {
        const newRedirect = new NewRedirect();
        newRedirect.init();
    });

})();