User:PexEric/IPE-RedirectHelper.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ 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();
});
})();