User:SuperGrey/gadgets/RedirectHelper/main.js
外观
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
// Main page: [[User:SuperGrey/gadgets/RedirectHelper]]
// <nowiki>
// Core configuration and utilities
const CONFIG = {
STYLES: `
#create-redirect-button { margin-bottom: 20px }
#redirect-helper-box {
margin-right: auto;
margin-bottom: 25px !important;
margin-left: auto;
background-color: #f5f5f5;
width: 700px;
max-width: calc(100% - 50px);
color: #202122;
}
.redirect-input-layout label { font-weight: 700 }
.redirect-helper-redirect-possibilities:after {
content: " (redirect with possibilities)";
font-style: italic;
}
.redirect-helper-template-parameters-container,
.redirect-helper-template-parameters-container details {
margin-top: 10px;
margin-bottom: 10px;
border-radius: 5px;
background-color: #e2e2e2;
padding: 5px;
}
.redirect-helper-template-parameters-container summary {
cursor: pointer;
font-weight: 700;
}
.redirect-helper-template-parameters-container details {
margin-top: 5px;
margin-bottom: 5px;
background-color: #d1cece;
}
#redirect-helper-no-templates-message { padding: 5px }
#redirect-helper-summary-layout {
margin-top: 15px;
border-top: 1px solid gray;
padding-top: 10px;
}
#redirect-helper-submit-layout { margin-top: 10px }
#redirect-helper-submit-layout > * { margin-bottom: 0 }
.redirect-helper-warning { margin-top: 8px }
.redirect-helper-autofix-button {
margin-left: 5px;
font-size: 12px;
}
.redirect-helper-autofix-button a {
padding: 3px 4px !important;
min-height: 0 !important;
min-height: initial !important;
}
`,
REQUIRED_MODULES: [
'mediawiki.util',
'oojs-ui-core',
'oojs-ui-widgets',
'oojs-ui-windows',
'oojs-ui.styles.icons-content',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-interactions',
'oojs-ui.styles.icons-layout',
'oojs-ui.styles.icons-moderation',
'oojs-ui.styles.icons-editing-core'
],
SCRIPT_MESSAGE: ' ([[User:SuperGrey/gadgets/RedirectHelper|RedirectHelper]])'
};
// Utility functions
const Utils = {
async fetchRedirectTemplates(api) {
return JSON.parse(
(
await api.get({
action: "query",
formatversion: "2",
prop: "revisions",
rvprop: "content",
rvslots: "main",
titles: "User:SuperGrey/gadgets/RedirectHelper/categories.json",
})
).query.pages[0]?.revisions?.[0]?.slots?.main?.content || "{}",
);
},
async getPageContent(api, title) {
const response = await api.get({
action: 'query',
formatversion: '2',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
titles: title
});
return response.query.pages[0].revisions[0].slots.main.content.trim();
},
async editOrCreate(api, title, content, summary, watchlist = 'preferences') {
try {
return await api.edit(title, () => ({ text: content, summary }));
} catch (error) {
if (error === 'nocreate-missing') {
return await api.create(title, { summary, watchlist }, content);
}
mw.notify(`Error editing or creating ${title}: ${error?.error?.info ?? 'Unknown error'} (${error})`, { type: 'error' });
return null;
}
}
};
// Custom input widgets
class RedirectInputWidget extends OO.ui.TextInputWidget {
constructor(config, pageTitleParsed) {
super(config);
OO.ui.mixin.LookupElement.call(this, config);
this.api = new mw.Api({ userAgent: 'RedirectHelper/2.0.0' });
this.pageTitleParsed = pageTitleParsed;
}
}
OO.mixinClass(RedirectInputWidget, OO.ui.mixin.LookupElement);
RedirectInputWidget.prototype.getLookupRequest = function () {
return this.api.get({
action: 'query',
formatversion: '2',
list: 'search',
srsearch: this.getValue(),
srnamespace: '*',
srlimit: 10
});
};
RedirectInputWidget.prototype.getLookupMenuOptionsFromData = function (data) {
const list = data?.query?.search ?? [];
return list.map(result => {
const title = mw.Title.newFromText(result.title);
return new OO.ui.MenuOptionWidget({
data: title.getPrefixedText(),
label: title.getPrefixedText()
});
});
};
RedirectInputWidget.prototype.getLookupCacheDataFromResponse = function (data) {
return data?.query?.search ?? [];
};
RedirectInputWidget.prototype.getLookupCacheListFromData = function (cacheData) {
return cacheData.map(result => {
const title = mw.Title.newFromText(result.title);
return new OO.ui.MenuOptionWidget({
data: title.getPrefixedText(),
label: title.getPrefixedText()
});
});
};
class CategoryInputWidget extends OO.ui.TextInputWidget {
constructor(config) {
super(config);
OO.ui.mixin.LookupElement.call(this, config);
this.api = new mw.Api({ userAgent: 'RedirectHelper/2.0.0' });
this.validCategories = new Set();
}
}
OO.mixinClass(CategoryInputWidget, OO.ui.mixin.LookupElement);
CategoryInputWidget.prototype.getLookupRequest = function () {
return this.api.get({
action: 'query',
formatversion: '2',
list: 'search',
srsearch: `Category:${this.getValue()}`,
srnamespace: 14,
srlimit: 10
});
};
CategoryInputWidget.prototype.getLookupMenuOptionsFromData = function (data) {
return data.query.search.map(result => {
const title = mw.Title.newFromText(result.title);
const name = title.getName();
this.validCategories.add(name);
return new OO.ui.MenuOptionWidget({
data: name,
label: name
});
});
};
// Dialog classes
class OutputPreviewDialog extends OO.ui.ProcessDialog {
constructor(config, pageTitleParsed) {
super(config);
this.pageTitleParsed = pageTitleParsed;
this.api = new mw.Api({ userAgent: 'RedirectHelper/2.0.0' });
OutputPreviewDialog.static.name = 'OutputPreviewDialog';
OutputPreviewDialog.static.title = 'Redirect categorization templates preview';
OutputPreviewDialog.static.actions = [
{
action: 'cancel',
label: 'Close',
flags: ['safe', 'close']
}
];
}
getSetupProcess() {
return super.getSetupProcess().next(() => {
return this.api.post({
action: 'parse',
formatversion: '2',
contentmodel: 'wikitext',
prop: ['text', 'categorieshtml'],
title: this.pageTitleParsed.getPrefixedDb(),
text: this.getData()
}).then(data => {
const text = data.parse.text;
const categoriesHtml = data.parse.categorieshtml;
const panel = new OO.ui.PanelLayout({
padded: true,
expanded: false
});
panel.$element.append(text, categoriesHtml);
this.$body.append(panel.$element);
});
});
}
getActionProcess(action) {
if (action) {
return new OO.ui.Process(() => {
this.close();
});
}
return super.getActionProcess(action);
}
getTeardownProcess() {
return super.getTeardownProcess().next(() => {
this.$body.empty();
});
}
}
// Object.assign(OutputPreviewDialog.prototype, OO.ui.ProcessDialog.prototype);
class ChangesDialog extends OO.ui.ProcessDialog {
constructor(config) {
super(config);
this.hasLoadedDiffStyles = false;
this.api = new mw.Api({ userAgent: 'RedirectHelper/2.0.0' });
ChangesDialog.static.name = 'ChangesDialog';
ChangesDialog.static.title = '編輯預覽';
ChangesDialog.static.actions = [
{
action: 'cancel',
label: 'Close',
flags: ['safe', 'close']
}
];
}
getSetupProcess() {
return super.getSetupProcess().next(() => {
if (!this.hasLoadedDiffStyles) {
mw.loader.addLinkTag('https://www.mediawiki.org/w/load.php?modules=mediawiki.diff.styles&only=styles');
this.hasLoadedDiffStyles = true;
}
const [oldText, newText] = this.getData();
return this.api.post({
action: 'compare',
formatversion: '2',
prop: ['diff'],
fromslots: 'main',
'fromtext-main': oldText,
'fromcontentmodel-main': 'wikitext',
toslots: 'main',
'totext-main': newText,
'tocontentmodel-main': 'wikitext'
}).then(data => {
const diffBody = data.compare.body;
const noChangesMessage = new OO.ui.MessageWidget({
type: 'warning',
label: '沒有變化!'
});
const panel = new OO.ui.PanelLayout({
padded: true,
expanded: false
});
if (diffBody) {
panel.$element.append(`
<table class="diff diff-editfont-monospace">
<colgroup>
<col class="diff-marker">
<col class="diff-content">
<col class="diff-marker">
<col class="diff-content">
</colgroup>
<tbody>
${diffBody}
</tbody>
</table>
`);
} else {
panel.$element.append(noChangesMessage.$element[0]);
}
this.$body.append(panel.$element);
});
});
}
getActionProcess(action) {
if (action) {
return new OO.ui.Process(() => {
this.close();
});
}
return super.getActionProcess(action);
}
getTeardownProcess() {
return super.getTeardownProcess().next(() => {
this.$body.empty();
});
}
}
// Object.assign(ChangesDialog.prototype, OO.ui.ProcessDialog.prototype);
// Validator class
class RedirectValidator {
constructor(api) {
this.api = api;
}
async validateRedirectTarget(target, sourceTitle) {
if (!target) {
return {
valid: false,
message: '請輸入重定向目標!'
};
}
const targetParsed = mw.Title.newFromText(target);
if (!targetParsed) {
return {
valid: false,
message: '無效的重定向目標!'
};
}
if (targetParsed.getPrefixedText() === sourceTitle.getPrefixedText()) {
return {
valid: false,
message: '不能重定向到自身!'
};
}
// Check if target is a redirect
const targetInfo = await this.api.get({
action: 'query',
formatversion: '2',
prop: ['info', 'pageprops'],
titles: targetParsed.getPrefixedText()
});
const targetPage = targetInfo.query.pages[0];
if (targetPage.missing) {
return {
valid: false,
message: '重定向目標不存在!'
};
}
if (targetPage.redirect) {
return {
valid: true,
warning: '重定向目標已經是重定向',
message: '重定向目標已經是重定向。是否自動修復為此重定向的最終目標?'
};
}
if (targetPage.pageprops?.disambiguation) {
return {
valid: true,
warning: '重定向目標是消歧義頁面',
message: '重定向目標是消歧義頁面。確定要重定向到此頁面嗎?'
};
}
return {
valid: true
};
}
validateTemplateParameters(templates, parameters) {
const errors = [];
Object.entries(templates).forEach(([tag, template]) => {
if (!template.parameters) return;
const templateParams = parameters[tag] || {};
const requiredParams = Object.entries(template.parameters)
.filter(([_, config]) => config.required)
.map(([name]) => name);
requiredParams.forEach(param => {
if (!templateParams[param]) {
errors.push(`模板「${template.label}」缺少必填參數「${param}」`);
}
});
});
return {
valid: errors.length === 0,
errors
};
}
validateSummary(summary) {
if (!summary) {
return {
valid: false,
message: '請輸入編輯摘要!'
};
}
return {
valid: true
};
}
async validateAll(target, sourceTitle, templates, parameters, categories, summary) {
const targetValidation = await this.validateRedirectTarget(target, sourceTitle);
if (!targetValidation.valid) {
return targetValidation;
}
const templateValidation = this.validateTemplateParameters(templates, parameters);
if (!templateValidation.valid) {
return {
valid: false,
message: templateValidation.errors.join('\n')
};
}
const summaryValidation = this.validateSummary(summary);
if (!summaryValidation.valid) {
return summaryValidation;
}
return {
valid: true,
warning: targetValidation.warning,
message: targetValidation.message
};
}
}
// Editor UI class
class RedirectEditorUI {
constructor(editor) {
this.editor = editor;
this.api = editor.api;
this.validator = new RedirectValidator(this.api);
}
createEditorBox() {
return new OO.ui.PanelLayout({
id: 'redirect-helper-box',
padded: true,
expanded: false,
framed: true
});
}
createRedirectInput() {
const input = new RedirectInputWidget({
placeholder: '目標頁面名稱',
required: true
}, this.editor.pageTitleParsed);
input.on('blur', () => {
const target = input.getValue().trim();
if (!target) return;
// Re‑use your existing validator
this.validator.validateRedirectTarget(target, this.editor.pageTitleParsed)
.then(result => {
if (!result.valid) {
input.setValidity(false);
// optional: show tooltip or mw.notify( result.message )
} else {
input.setValidity(true);
}
});
});
input.on('change', () => {
let value = input.getValue();
value = value.replace(new RegExp(`^(https?:)?/{2}?${mw.config.get('wgServer').replace(/^\/{2}/, '')}/wiki/`), '');
value = value.replace(/^:/, '');
if (value.length > 0) {
input.setValue(value[0].toUpperCase() + value.slice(1).replaceAll('_', ' '));
this.editor.submitButton.setDisabled(false);
this.editor.showPreviewButton.setDisabled(false);
this.editor.showChangesButton.setDisabled(false);
} else {
this.editor.submitButton.setDisabled(true);
this.editor.showPreviewButton.setDisabled(true);
this.editor.showChangesButton.setDisabled(true);
}
this.editor.updateSummary();
this.editor.submitButton.setLabel('保存');
this.editor.needsCheck = true;
});
return new OO.ui.FieldLayout(input, {
label: '重定向目標:',
classes: ['redirect-input-layout'],
align: 'top'
});
}
createTagSelector() {
const tagOptions = Object.entries(this.editor.redirectTemplates).map(([key, template]) => {
if (!template.redirect) {
return { data: key, label: key };
}
const label = new OO.ui.HtmlSnippet(`<span class="redirect-helper-redirect-possibilities">${key}</span>`);
return { data: key, label: label };
});
const selector = new OO.ui.MenuTagMultiselectWidget({
allowArbitrary: false,
allowReordering: false,
options: tagOptions
});
selector.getMenu().filterMode = 'substring';
selector.on('change', (items) => {
const values = items.map(item => item.getData());
const sortedValues = values.toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (values.join(';') !== sortedValues.join(';')) {
const lastItem = items.at(-1);
selector.reorder(lastItem, sortedValues.indexOf(lastItem.getData()));
}
this.editor.updateSummary();
this.editor.submitButton.setLabel('保存');
this.editor.needsCheck = true;
// Hide all template editors
for (const info of this.editor.templateEditorsInfo) {
info.details.style.display = 'none';
}
// Show only selected template editors
let count = 0;
for (const tag of selector.getValue()) {
const editor = this.editor.templateEditorsInfo.find(info => info.name === tag);
if (editor) {
editor.details.style.display = 'block';
count++;
}
}
this.editor.templateParametersEditor.querySelector('summary').textContent =
`Template parameters (${count > 0 ? `for ${count} template${count > 1 ? 's' : ''}` : 'none to show'})`;
this.editor.templateParametersEditor.querySelector('#redirect-helper-no-templates-message').style.display =
count > 0 ? 'none' : 'block';
});
return new OO.ui.FieldLayout(selector, {
label: '重定向分類模板:',
classes: ['redirect-input-layout'],
align: 'top'
});
}
createTemplateParametersEditor() {
return new OO.ui.PanelLayout({
classes: ['redirect-helper-template-parameters-container']
});
}
createSummaryInput() {
const input = new OO.ui.ComboBoxInputWidget({
options: [
{ data: '修復雙重重定向' },
{ data: '修復循環重定向' },
{ data: '移除誤植的重定向分類模板' }
]
});
return new OO.ui.FieldLayout(input, {
id: 'redirect-helper-summary-layout',
label: '編輯摘要:',
classes: ['redirect-input-layout'],
align: 'top'
});
}
createActionButtons() {
const submitButton = new OO.ui.ButtonWidget({
label: '保存',
icon: 'check',
flags: ['primary', 'progressive']
});
const previewButton = new OO.ui.ButtonWidget({
label: '預覽',
icon: 'eye',
flags: ['progressive']
});
const cancelButton = new OO.ui.ButtonWidget({
label: '取消',
icon: 'close',
flags: ['destructive']
});
submitButton.on('click', () => this.editor.handleSubmitButtonClick());
previewButton.on('click', () => {
const output = this.editor.createOutput();
if (output) {
const dialog = new OutputPreviewDialog({
size: 'large'
}, this.editor.pageTitleParsed);
dialog.getManager().addWindows([dialog]);
dialog.getManager().openWindow(dialog);
dialog.getData = () => output;
}
});
cancelButton.on('click', () => this.handleCancel());
return new OO.ui.FieldLayout(
new OO.ui.HorizontalLayout({
items: [submitButton, previewButton, cancelButton]
}),
{
align: 'left',
label: '操作'
}
);
}
createWarningMessage(message, autofixCallback) {
const warning = new OO.ui.MessageWidget({
type: 'warning',
label: message,
icon: 'alert'
});
if (autofixCallback) {
const autofixButton = new OO.ui.ButtonWidget({
label: '自動修復',
flags: ['progressive']
});
autofixButton.on('click', () => {
autofixCallback();
warning.$element[0].remove();
});
warning.$element[0].querySelector('.oo-ui-labelElement-label').append(autofixButton.$element[0]);
}
return warning;
}
handleCancel() {
this.editor.editorBox.$element[0].remove();
if (this.editor.isNewPage) {
const button = new OO.ui.ButtonWidget({
id: 'create-redirect-button',
label: '創建重定向',
icon: 'articleRedirect',
flags: ['progressive']
});
button.on('click', () => {
button.$element[0].remove();
this.editor.load();
});
this.editor.contentText.prepend(button.$element[0]);
} else {
const portletId = mw.config.get('skin') === 'minerva' ? 'p-tb' : 'p-cactions';
const portlet = mw.util.addPortletLink(portletId, '#', '編輯重定向', 'redirect-helper');
portlet.addEventListener('click', (event) => {
event.preventDefault();
this.editor.load();
window.scrollTo({ top: 0, behavior: 'smooth' });
portlet.remove();
});
}
}
showValidationDialog(title, message, actions) {
const dialog = new OO.ui.MessageDialog({
size: 'medium',
title,
message,
actions
});
dialog.getManager().addWindows([dialog]);
dialog.getManager().openWindow(dialog);
return dialog;
}
}
// Editor class
class RedirectEditor {
constructor(config, exists, createdWatchMethod) {
this.api = new mw.Api({ userAgent: 'RedirectHelper/2.0.0' });
this.redirectTemplates = config.redirectTemplates;
this.contentText = config.contentText;
this.pageTitle = config.pageTitle;
this.pageTitleParsed = config.pageTitleParsed;
this.exists = exists;
this.createdWatchMethod = createdWatchMethod;
this.redirectRegex = /^#.*?:?\s*\[\[\s*:?([^[\]{|}]+?)\s*(?:\|[^[\]{|}]+?)?]]\s*/i;
// Initialize state
this.needsCheck = true;
this.pageContent = '';
this.oldRedirectTarget = null;
this.oldRedirectTags = [];
this.oldRedirectTagData = null;
this.oldStrayText = '';
this.parsedDestination = null;
// Initialize UI components
this.initializeUIComponents();
}
initializeUIComponents() {
// Initialize redirect input
this.redirectInput = new RedirectInputWidget({
placeholder: '目標頁面名稱',
required: true
}, this.pageTitleParsed);
this.redirectInput.on('change', () => {
let value = this.redirectInput.getValue();
value = value.replace(new RegExp(`^(https?:)?/{2}?${mw.config.get('wgServer').replace(/^\/{2}/, '')}/wiki/`), '');
value = value.replace(/^:/, '');
if (value.length > 0) {
this.redirectInput.setValue(value[0].toUpperCase() + value.slice(1).replaceAll('_', ' '));
this.submitButton.setDisabled(false);
this.showPreviewButton.setDisabled(false);
this.showChangesButton.setDisabled(false);
} else {
this.submitButton.setDisabled(true);
this.showPreviewButton.setDisabled(true);
this.showChangesButton.setDisabled(true);
}
this.updateSummary();
this.submitButton.setLabel('保存');
this.needsCheck = true;
});
this.redirectInputLayout = new OO.ui.FieldLayout(this.redirectInput, {
label: '重定向目標:',
classes: ['redirect-input-layout'],
align: 'top'
});
// Initialize tag selector
const tagOptions = Object.entries(this.redirectTemplates).map(([key, template]) => {
if (!template.redirect) {
return { data: key, label: key };
}
const label = new OO.ui.HtmlSnippet(`<span class="redirect-helper-redirect-possibilities">${key}</span>`);
return { data: key, label: label };
});
this.tagSelect = new OO.ui.MenuTagMultiselectWidget({
allowArbitrary: false,
allowReordering: false,
options: tagOptions
});
this.tagSelect.getMenu().filterMode = 'substring';
this.tagSelect.on('change', (items) => {
const values = items.map(item => item.getData());
const sortedValues = values.toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (values.join(';') !== sortedValues.join(';')) {
const lastItem = items.at(-1);
this.tagSelect.reorder(lastItem, sortedValues.indexOf(lastItem.getData()));
}
this.updateSummary();
this.submitButton.setLabel('保存');
this.needsCheck = true;
// Hide all template editors
for (const info of this.templateEditorsInfo) {
info.details.style.display = 'none';
}
// Show only selected template editors
let count = 0;
for (const tag of this.tagSelect.getValue()) {
const editor = this.templateEditorsInfo.find(info => info.name === tag);
if (editor) {
editor.details.style.display = 'block';
count++;
}
}
this.templateParametersEditor.querySelector('summary').textContent =
`Template parameters (${count > 0 ? `for ${count} template${count > 1 ? 's' : ''}` : 'none to show'})`;
this.templateParametersEditor.querySelector('#redirect-helper-no-templates-message').style.display =
count > 0 ? 'none' : 'block';
});
this.tagSelectLayout = new OO.ui.FieldLayout(this.tagSelect, {
label: '重定向分類模板:',
classes: ['redirect-input-layout'],
align: 'top'
});
// Initialize template parameters editor
this.templateParametersEditor = document.createElement('details');
this.templateParametersEditor.classList.add('redirect-helper-template-parameters-container');
const summary = document.createElement('summary');
summary.textContent = '模板參數(無)';
this.templateParametersEditor.append(summary);
this.templateEditorsInfo = [];
for (const [tag, template] of Object.entries(this.redirectTemplates)) {
const parameters = Object.entries(template.parameters);
if (parameters.length === 0) continue;
const details = document.createElement('details');
details.style.display = 'none';
const paramSummary = document.createElement('summary');
paramSummary.textContent = tag;
details.append(paramSummary);
const editorInfo = {
name: tag,
details: details,
parameters: []
};
for (const [param, config] of parameters) {
const input = new OO.ui.TextInputWidget({
placeholder: config.default?.toString(),
required: config.required
});
input.on('change', () => {
this.updateSummary();
this.submitButton.setLabel('保存');
this.needsCheck = true;
});
const layout = new OO.ui.FieldLayout(input, {
label: new OO.ui.HtmlSnippet(
`${param}${!config.label || param.toLowerCase() === config.label.toLowerCase() ? '' : ` (${config.label})`}` +
`${config.description ? ` (${config.description})` : ''}` +
` (type: ${config.type})` +
`${config.suggested ? ' (建議)' : ''}` +
`${config.example ? ` (例:"${config.example}")` : ''}`
),
align: 'inline'
});
details.append(layout.$element[0]);
editorInfo.parameters.push({
name: param,
aliases: config.aliases,
editor: input
});
}
this.templateParametersEditor.append(details);
this.templateEditorsInfo.push(editorInfo);
}
const noTemplatesMessage = document.createElement('div');
noTemplatesMessage.id = 'redirect-helper-no-templates-message';
noTemplatesMessage.textContent = '無模板參數';
this.templateParametersEditor.append(noTemplatesMessage);
// Initialize summary input
this.summaryInput = new OO.ui.ComboBoxInputWidget({
options: [
{ data: '修復雙重重定向' },
{ data: '修復循環重定向' },
{ data: '移除誤植的重定向分類模板' }
]
});
this.summaryInputLayout = new OO.ui.FieldLayout(this.summaryInput, {
id: 'redirect-helper-summary-layout',
label: '編輯摘要:',
classes: ['redirect-input-layout'],
align: 'top'
});
}
async load() {
this.editorBox = new OO.ui.PanelLayout({
id: 'redirect-helper-box',
padded: true,
expanded: false,
framed: true
});
this.loadInputElements();
await this.loadSubmitElements();
// Add the submit layout to the editor box
this.editorBox.$element.append(this.submitLayout.$element);
this.contentText.prepend(this.editorBox.$element[0]);
if (this.exists) {
await this.loadExistingData();
}
}
loadInputElements() {
// Create a container for all input elements
const inputContainer = $('<div>').addClass('input-container');
// Add all input elements to the container
inputContainer.append(
this.redirectInputLayout.$element,
this.tagSelectLayout.$element,
this.templateParametersEditor.$element,
this.summaryInputLayout.$element
);
// Add the input container to the editor box
this.editorBox.$element.append(inputContainer);
}
async loadSubmitElements() {
const windowManager = new OO.ui.WindowManager();
document.body.appendChild(windowManager.$element[0]);
this.submitButton = new OO.ui.ButtonWidget({
label: '保存',
disabled: true,
flags: ['progressive']
});
this.submitButton.on('click', () => this.handleSubmitButtonClick());
// Initialize OutputPreviewDialog
const outputPreviewDialog = new OutputPreviewDialog({
size: 'large'
}, this.pageTitleParsed);
windowManager.addWindows([outputPreviewDialog]);
this.showPreviewButton = new OO.ui.ButtonWidget({
label: '顯示預覽',
disabled: true
});
this.showPreviewButton.on('click', () => {
outputPreviewDialog.setData(this.createOutput(
this.redirectInput.getValue(),
this.tagSelect.getValue(),
this.oldStrayText,
));
outputPreviewDialog.open();
});
// Initialize ChangesDialog
const changesDialog = new ChangesDialog({
size: 'large'
});
windowManager.addWindows([changesDialog]);
this.showChangesButton = new OO.ui.ButtonWidget({
label: '顯示變更',
disabled: true
});
this.showChangesButton.on('click', async () => {
if (this.exists) {
this.pageContent = await this.getPageContent(this.pageTitle);
}
changesDialog.setData([
this.pageContent,
this.createOutput(
this.redirectInput.getValue(),
this.tagSelect.getValue(),
this.oldStrayText,
)
]);
changesDialog.open();
});
// Handle talk page sync checkbox
if (!this.pageTitleParsed.isTalkPage()) {
this.talkData = await this.api.get({
action: 'query',
formatversion: '2',
prop: 'info',
titles: this.pageTitleParsed.getTalkPage().getPrefixedText()
});
this.syncTalkCheckbox = new OO.ui.CheckboxInputWidget({
selected: !!this.talkData.query.pages[0].redirect
});
this.syncTalkCheckboxLayout = new OO.ui.Widget({
content: [
new OO.ui.FieldLayout(this.syncTalkCheckbox, {
label: '同步討論頁',
align: 'inline'
})
]
});
}
// Handle watch checkbox for new pages
if (!this.exists) {
const watchConfig = {};
if (['nochange', 'preferences'].includes(this.createdWatchMethod)) {
watchConfig.indeterminate = true;
} else {
watchConfig.selected = this.createdWatchMethod === 'watch';
}
this.watchCheckbox = new OO.ui.CheckboxInputWidget(watchConfig);
this.watchCheckboxLayout = new OO.ui.Widget({
content: [
new OO.ui.FieldLayout(this.watchCheckbox, {
label: '監視頁面',
align: 'inline'
})
]
});
}
// Handle patrol checkbox
if (await this.checkShouldPromptPatrol()) {
this.patrolCheckbox = new OO.ui.CheckboxInputWidget({
selected: true
});
this.patrolCheckboxLayout = new OO.ui.Widget({
content: [
new OO.ui.FieldLayout(this.patrolCheckbox, {
label: '標記為已巡查',
align: 'inline'
})
]
});
}
this.submitLayout = new OO.ui.HorizontalLayout({
id: 'redirect-helper-submit-layout',
items: [
this.submitButton,
this.showPreviewButton,
this.showChangesButton,
this.syncTalkCheckboxLayout,
this.watchCheckboxLayout,
this.patrolCheckboxLayout
].filter(Boolean)
});
}
async checkShouldPromptPatrol() {
console.log("Starting checkShouldPromptPatrol");
console.log("Current namespace:", mw.config.get("wgNamespaceNumber"));
console.log("Article ID:", mw.config.get("wgArticleId"));
const patrolIcon = document.querySelector("#mwe-pt-mark .mwe-pt-tool-icon");
console.log("Patrol icon found:", !!patrolIcon);
if (patrolIcon) {
console.log("Attempting to click patrol icon");
patrolIcon.click();
patrolIcon.click();
}
if (mw.config.get("wgNamespaceNumber") !== 0) {
console.log("Not in main namespace, returning false");
return false;
}
if (document.querySelector(".patrollink")) {
console.log("Found patrollink, returning true");
return true;
}
if (document.querySelector("#mwe-pt-mark-as-reviewed-button")) {
console.log("Found mark-as-reviewed button, returning true");
return true;
}
if (document.querySelector("#mwe-pt-mark-as-unreviewed-button")) {
console.log("Found mark-as-unreviewed button, returning false");
return false;
}
try {
const userRights = await mw.user.getRights();
console.log("User rights:", userRights);
if (!mw.config.get("wgArticleId") || !userRights.includes("patrol")) {
console.log("No article ID or no patrol rights, returning false");
return false;
}
console.log("Fetching recent changes for article ID:", mw.config.get("wgArticleId"));
const result = await this.api.get({
action: "query",
list: "recentchanges",
rctitle: mw.config.get("wgPageName"),
rcprop: "patrolled|user|autopatrolled",
rclimit: 1
}).catch(error => {
console.log("API error details:", error);
return null;
});
console.log("Recent changes result:", result);
if (!result || !result.query?.recentchanges?.length) {
console.log("No recent changes found");
return false;
}
const change = result.query.recentchanges[0];
console.log("Most recent change:", change);
// Check if the change needs patrolling
// Empty string for patrolled means it needs patrolling
// Empty string for autopatrolled means it's not autopatrolled
const needsPatrolling = change.patrolled === "" && change.autopatrolled === "" && change.user !== mw.config.get("wgUserName");
console.log("Needs patrolling:", needsPatrolling);
return needsPatrolling;
} catch (error) {
console.error("Error in checkShouldPromptPatrol:", error);
return false;
}
}
updateSummary() {
const redirectTarget = this.redirectInput.getValue().trim();
if (!redirectTarget) {
this.summaryInput.$tabIndexed[0].placeholder = '';
return;
}
if (this.exists) {
let oldTarget = this.oldRedirectTarget?.replaceAll('_', ' ');
if (oldTarget) {
oldTarget = oldTarget[0].toUpperCase() + oldTarget.slice(1);
}
const targetChanged = redirectTarget !== oldTarget;
const tagsChanged = this.tagSelect.getValue().some(tag => !this.oldRedirectTags.includes(tag)) ||
this.oldRedirectTags.some(tag => !this.tagSelect.getValue().includes(tag));
let templateParamsChanged = false;
if (this.oldRedirectTagData) {
const templatesWithParams = Object.entries(this.redirectTemplates)
.filter(([, template]) => Object.entries(template.parameters).length > 0);
for (const [tag, template] of templatesWithParams) {
if (!this.oldRedirectTags.includes(tag) || !this.tagSelect.getValue().includes(tag)) {
continue;
}
const oldParams = this.oldRedirectTagData[tag] ??
Object.entries(template.parameters).map(([name]) => [name, '']);
const editor = this.templateEditorsInfo.find(info => info.name === tag);
for (const param of editor.parameters) {
const oldValue = oldParams.find(([name]) => name === param.name)?.[1] ?? '';
const newValue = param.editor.getValue().trim();
if (oldValue !== newValue) {
templateParamsChanged = true;
break;
}
}
if (templateParamsChanged) break;
}
}
const changes = [];
if (targetChanged) changes.push(`修改重定向目標到「[[${redirectTarget}]]」`);
if (tagsChanged) {
changes.push(
this.tagSelect.getValue().length > 0 && this.oldRedirectTags.length > 0 ? '修改' :
this.tagSelect.getValue().length > 0 ? '添加' : '移除',
'分類模板'
);
}
if (templateParamsChanged) changes.push('修改分類模板參數');
if (changes.length === 0) {
changes.push('清理重定向');
}
changes[0] = changes[0][0].toUpperCase() + changes[0].slice(1);
if (changes.length > 1) {
changes[changes.length - 1] = `${changes.at(-1)}`;
}
this.summaryInput.$tabIndexed[0].placeholder = changes.join(changes.length > 2 ? '、' : '');
} else {
this.summaryInput.$tabIndexed[0].placeholder = `新重定向到 [[${redirectTarget}]]`;
}
}
async handleSubmitButtonClick() {
const elements = [
this.redirectInput,
this.tagSelect,
...this.templateEditorsInfo.flatMap(info => info.parameters.map(param => param.editor)),
this.summaryInput,
this.submitButton,
this.showPreviewButton,
this.showChangesButton,
this.syncTalkCheckbox,
this.watchCheckbox,
this.patrolCheckbox
].filter(Boolean);
for (const element of elements) {
element.setDisabled(true);
}
this.submitButton.setLabel('檢查目標頁面是否存在……');
let errors = [];
if (this.needsCheck) {
errors = await this.validateSubmission();
} else {
this.parsedDestination = mw.Title.newFromText(this.redirectInput.getValue());
}
if (errors.length > 0) {
for (const warning of document.querySelectorAll('.redirect-helper-warning')) {
warning.remove();
}
for (const { title, message, autoFixes } of errors) {
const label = new OO.ui.HtmlSnippet(
`${title ? `<a href="${mw.util.getUrl(title)}" target="_blank">${title}</a>` : '本頁面'} ${message} 再次點擊保存可無視錯誤提交。`
);
const warning = new OO.ui.MessageWidget({
type: 'error',
classes: ['redirect-helper-warning'],
inline: true,
label
});
if (autoFixes) {
const autofixButton = new OO.ui.ButtonWidget({
label: '自動修復',
flags: ['progressive'],
classes: ['redirect-helper-autofix-button']
});
autofixButton.on('click', () => {
const currentTags = this.tagSelect.getValue();
for (const fix of autoFixes) {
if (fix.type === 'add' && !currentTags.includes(fix.tag)) {
this.tagSelect.addTag(fix.tag, fix.tag);
} else if (fix.type === 'remove' && currentTags.includes(fix.tag)) {
this.tagSelect.removeTagByData(fix.tag);
} else if (fix.type === 'change-target') {
this.redirectInput.setValue(fix.target);
}
}
warning.$element[0].style.textDecoration = 'line-through 2px black';
autofixButton.$element[0].remove();
});
warning.$element[0].querySelector('.oo-ui-labelElement-label').append(autofixButton.$element[0]);
}
this.editorBox.$element[0].append(warning.$element[0]);
}
for (const element of elements) {
element.setDisabled(false);
}
this.submitButton.setLabel('仍保存');
this.needsCheck = false;
return;
}
this.submitButton.setLabel(`正在${this.exists ? '修改' : '創建'}重定向……`);
const output = this.createOutput(
this.redirectInput.getValue(),
this.tagSelect.getValue(),
this.oldStrayText,
);
const summary = (this.summaryInput.getValue() || this.summaryInput.$tabIndexed[0].placeholder) + CONFIG.SCRIPT_MESSAGE;
if (await this.editOrCreate(this.pageTitle, output, summary)) {
mw.notify(`重定向${this.exists ? '修改' : '創建'}成功!`, { type: 'success' });
if (this.syncTalkCheckbox?.isSelected()) {
this.submitButton.setLabel('正在編輯討論頁...');
const isMove = this.tagSelect.getValue().includes('移動重定向');
const talkOutput = this.createOutput(
this.parsedDestination.getTalkPage().getPrefixedText(),
isMove ? ['移動重定向'] : [],
undefined,
undefined,
[]
);
if (!await this.editOrCreate(
this.pageTitleParsed.getTalkPage().getPrefixedText(),
talkOutput,
'同步主頁面重定向' + CONFIG.SCRIPT_MESSAGE
)) {
return;
}
mw.notify('討論頁同步成功!', { type: 'success' });
}
if (this.patrolCheckbox?.isSelected()) {
this.submitButton.setLabel('正在巡查重定向……');
const patrolLink = document.querySelector('.patrollink a');
const reviewButton = document.querySelector('#mwe-pt-mark-as-reviewed-button');
if (patrolLink) {
if (await this.api.postWithToken('patrol', {
action: 'patrol',
rcid: new URL(patrolLink.href).searchParams.get('rcid')
}).catch((error, result) => {
mw.notify(`巡查「${this.pageTitle}」失敗:${result?.error?.info ?? '未知錯誤'} (${error})`, { type: 'error' });
return null;
})) {
mw.notify('重定向巡查成功!', { type: 'success' });
}
} else if (reviewButton) {
reviewButton.click();
mw.notify('重定向巡查成功!', { type: 'success' });
} else {
mw.notify('未找到頁面巡查工具欄,巡查失敗!', { type: 'error' });
}
}
this.submitButton.setLabel('完成,正在重新載入頁面……');
window.location.href = mw.util.getUrl(this.pageTitle, { redirect: 'no' });
}
}
createOutput(target, tags, strayText) {
const parsedTarget = mw.Title.newFromText(target);
const targetText = parsedTarget ?
`${parsedTarget.getNamespaceId() === 14 ? ':' : ''}${parsedTarget.getPrefixedText()}${parsedTarget.getFragment() ? `#${parsedTarget.getFragment()}` : ''}` :
target.trim();
const templateOutput = tags.map(tag => {
const editor = this.templateEditorsInfo.find(info => info.name === tag);
if (!editor) return `{{${tag}}}`;
const lastPositionalIndex = editor.parameters.findLastIndex((param, index) =>
param.name === (index + 1).toString() && param.editor.getValue().trim()
);
const params = editor.parameters.map((param, index) => {
const value = param.editor.getValue().trim();
if (!value && index > lastPositionalIndex) return null;
return `|${param.name === (index + 1).toString() ? '' : `${param.name}=`}${value}`;
}).filter(Boolean).join('');
return `{{${tag}${params}}}`;
});
return [
`#REDIRECT [[${targetText}]]`,
tags.length > 0 ? `{{Redirect category shell|\n${templateOutput.join('\n')}\n}}` : null,
strayText ? strayText + '\n' : null,
].filter(Boolean).join('\n');
}
async getPageContent(title) {
return (await this.api.get({
action: 'query',
formatversion: '2',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
titles: title
})).query.pages[0].revisions[0].slots.main.content.trim();
}
async editOrCreate(title, content, summary) {
let watchlist = 'preferences';
if (this.watchCheckbox) {
if (this.watchCheckbox.isIndeterminate()) {
watchlist = this.createdWatchMethod;
} else if (this.watchCheckbox.isSelected()) {
watchlist = 'watch';
} else {
watchlist = 'unwatch';
}
}
return await this.api.edit(title, () => ({ text: content, summary }))
.catch((error, result) => {
if (error === 'nocreate-missing') {
return this.api.create(title, { summary, watchlist }, content)
.catch((error, result) => {
mw.notify(`創建「${title}」失敗:${result?.error?.info ?? '未知錯誤'} (${error})`, { type: 'error' });
return null;
});
}
mw.notify(`創建或編輯「${title}」失敗:${result?.error?.info ?? '未知錯誤'} (${error})`, { type: 'error' });
return null;
});
}
async validateSubmission() {
const errors = [];
const redirectTarget = this.redirectInput.getValue().trim();
const selectedTags = this.tagSelect.getValue();
// Basic title validation
if (!/^\s*[^[\]{|}]+\s*$/.test(redirectTarget)) {
errors.push({
title: redirectTarget,
message: '並非有效的頁面名稱!'
});
}
// Parse target title
try {
this.parsedDestination = mw.Title.newFromText(redirectTarget);
} catch {
if (errors.length === 0) {
errors.push({
title: redirectTarget,
message: '並非有效的頁面名稱!'
});
}
}
if (!this.parsedDestination && errors.length === 0) {
errors.push({
title: redirectTarget,
message: '並非有效的頁面名稱!'
});
}
// Check self-redirect
if (this.parsedDestination?.getPrefixedText() === this.pageTitleParsed.getPrefixedText()) {
errors.push({
message: '不能重定向到自身!',
});
}
// Check target existence and properties
const targetInfo = await this.api.get({
action: 'query',
formatversion: '2',
prop: ['pageprops', 'categories'],
titles: redirectTarget
}).catch(error => {
if (error === 'missingtitle') {
errors.push({
title: redirectTarget,
message: '重定向目標不存在!'
});
} else {
errors.push({
title: redirectTarget,
message: `無法通過 API 獲取 (${error})!`
});
}
return null;
});
// Check for redirects
let parseInfo;
try {
parseInfo = await this.api.get({
action: 'parse',
page: redirectTarget,
prop: 'sections',
redirects: true
});
} catch (error) {
if (error === 'missingtitle') {
errors.push({
title: redirectTarget,
message: '重定向目標不存在!'
});
} else {
errors.push({
title: redirectTarget,
message: `parse API 錯誤 (${error})!`
});
}
// give yourself a safe default so the rest of the logic can run
parseInfo = { parse: { redirects: [], sections: [] } };
}
if (parseInfo.parse.redirects.length > 0) {
const redirectTarget = parseInfo.parse.redirects[0].to +
(parseInfo.parse.redirects[0].tofragment ? `#${parseInfo.parse.redirects[0].tofragment}` : '');
errors.push({
title: redirectTarget,
message: `已重定向到「<a href="${mw.util.getUrl(redirectTarget)}" target="_blank">${redirectTarget}</a>」。請直接重定向到該頁面,以避免雙重重定向!`,
autoFixes: [{
type: 'change-target',
target: redirectTarget
}]
});
}
// Check section/anchor redirects
if (redirectTarget.split('#').length > 1) {
if (parseInfo.parse.sections.find(section =>
section.line.replaceAll(/<\/?i>/g, '') === redirectTarget.split('#')[1])) {
if (selectedTags.includes('章節重定向')) {
errors.push({
message: '被標記為「章節重定向」,但其實是錨點重定向!',
autoFixes: [
{ type: 'add', tag: '錨點重定向' },
{ type: 'remove', tag: '章節重定向' }
]
});
} else if (!selectedTags.includes('章節重定向')) {
errors.push({
message: '是錨點重定向,但尚未被標記上 <code>{{錨點重定向}}</code>!',
autoFixes: [{ type: 'add', tag: '錨點重定向' }]
});
}
} else {
const targetContent = (await this.api.get({
action: 'query',
formatversion: '2',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
titles: this.parsedDestination.getPrefixedText()
})).query.pages[0].revisions[0].slots.main.content;
const anchors = [
...targetContent.match(/(?<={{\s*?[Aa](?:nchors?|nchor for redirect|nker|NCHOR|nc)\s*?\|).+?(?=}})/g)?.map(a =>
a.split('|').map(p => p.trim())).flat() ?? [],
...targetContent.match(/(?<={{\s*?(?:[Vv](?:isible anchors?|isanc|Anch|anchor|isibleanchor|a)|[Aa](?:nchord|chored|nchor\+)|[Tt]ext anchor)\s*?\|).+?(?=(?<!!|=)}})/g)?.map(a =>
a.split('|').map(p => p.trim()).filter(p => !/^text\s*?=/.test(p))).flat() ?? [],
...targetContent.match(/(?<=id=)"?.+?(?="|>|\|)/g)?.map(a => a.trim()) ?? [],
...targetContent.match(/EpisodeNumber += +\d+/g)?.map(a => `ep${a.split('=')[1].trim()}`) ?? []
];
if (anchors.includes(redirectTarget.split('#')[1])) {
if (selectedTags.includes('章節重定向')) {
errors.push({
message: '被標記為「章節重定向」,但其實是錨點重定向!',
autoFixes: [
{ type: 'add', tag: '錨點重定向' },
{ type: 'remove', tag: '章節重定向' }
]
});
} else if (!selectedTags.includes('章節重定向')) {
errors.push({
message: '是錨點重定向,但尚未被標記上 <code>{{錨點重定向}}</code>!',
autoFixes: [{ type: 'add', tag: '錨點重定向' }]
});
}
} else {
errors.push({
message: `重定向到「<a href="${mw.util.getUrl(redirectTarget)}」" target="_blank">${redirectTarget}</a>,但該章節或錨點並不存在!`,
autoFixes: [{
type: 'change-target',
target: redirectTarget.split('#')[0]
}]
});
}
}
}
// Check for section/anchor tags on non-section/anchor redirects
if (redirectTarget.split('#').length === 1) {
for (const tag of ['章節重定向', '錨點重定向']) {
if (selectedTags.includes(tag)) {
errors.push({
message: `並非到章節或錨點的重定向,但卻被誤植了 <code>{{${tag}}}</code>!`,
autoFixes: [{ type: 'remove', tag }]
});
}
}
}
// Check disambiguation page handling
const isDisambiguation = !!(targetInfo.query.pages[0].pageprops && 'disambiguation' in targetInfo.query.pages[0].pageprops);
const isSurnameList = !!targetInfo.query.pages[0].categories?.some(cat => cat.title === 'Category:姓氏');
const disambiguationTags = ['消歧義頁重定向', 'R from incomplete disambiguation'];
const ambiguousTags = ['R from ambiguous sort name', 'R from ambiguous term'];
const hasDisambiguationTag = disambiguationTags.some(tag => selectedTags.includes(tag));
const hasAmbiguousTag = ambiguousTags.some(tag => selectedTags.includes(tag));
if (isDisambiguation && !hasDisambiguationTag && !hasAmbiguousTag) {
errors.push({
message: '是到消歧義頁的重定向,但並未被標記上 <code>{{消歧義頁重定向}}</code>!'
});
}
if (targetInfo.query.pages[0].pageprops && !isDisambiguation) {
if ((!isSurnameList && (hasDisambiguationTag || hasAmbiguousTag)) || (isSurnameList && hasDisambiguationTag)) {
errors.push({
message: '並非到消歧義頁的重定向,但卻被標記了 <code>{{消歧義頁重定向}}</code>!',
autoFixes: [...disambiguationTags, ...ambiguousTags].map(tag => ({ type: 'remove', tag }))
});
}
if (isSurnameList && !hasAmbiguousTag) {
errors.push({
message: '是到同姓列表的重定向,但並未被標記上正確的消歧義分類模板!'
});
}
}
// if (isDisambiguation && selectedTags.includes('消歧義頁重定向') &&
// !this.pageTitleParsed.getMainText().endsWith(' (disambiguation)')) {
// errors.push({
// message: '被標記了 <code>{{消歧義頁重定向}}</code>,但頁面名稱並未以 " (消歧義)" 結尾。Use <code>{{R from ambiguous term}}</code> or a similar categorization template instead!',
// autoFixes: [{ type: 'remove', tag: '消歧義頁重定向' }]
// });
// }
// Check protection tags
for (const tag of ['R protected', 'R semi-protected', 'R extended-protected', 'R template-protected', 'R fully protected']) {
if (selectedTags.includes(tag)) {
errors.push({
message: `is tagged with unnecessarily tagged with <code>{{${tag}}}</code> which will be duplicated by the redirect category shell!`,
autoFixes: [{ type: 'remove', tag }]
});
}
}
// Check Wikidata item
if (mw.config.get('wgWikibaseItemId') && !selectedTags.includes('維基數據項目重定向')) {
errors.push({
message: '被連接到維基數據項目,但尚未被標記上 <code>{{維基數據項目重定向}}</code>!',
autoFixes: [{ type: 'add', tag: '維基數據項目重定向' }]
});
} else if (selectedTags.includes('維基數據項目重定向') && !mw.config.get('wgWikibaseItemId')) {
errors.push({
message: '被標記了 <code>{{維基數據項目重定向}}</code> 但還未連接到維基數據項目!',
autoFixes: [{ type: 'remove', tag: '維基數據項目重定向' }]
});
}
// Check template parameters
for (const tag of selectedTags) {
if (!(tag in this.redirectTemplates)) continue;
const template = this.redirectTemplates[tag];
for (const [param, config] of Object.entries(template.parameters)) {
const editor = this.templateEditorsInfo.find(info => info.name === tag)
?.parameters.find(p => [p.name, ...p.aliases].includes(param));
if (editor && config.required && !editor.editor.getValue().trim()) {
errors.push({
message: `被標記了 <code>{{${tag}}}</code> 但缺少必要參數「<code>${param}</code>」!`
});
}
}
}
// Check talk page sync
if (this.syncTalkCheckbox?.isSelected() &&
!this.talkData.query.pages[0].missing &&
!this.talkData.query.pages[0].redirect) {
errors.push({
title: this.pageTitleParsed.getTalkPage().getPrefixedText(),
message: '已存在,但並非重定向!'
});
}
return errors;
}
async loadExistingData() {
if (this.exists) {
this.pageContent = await this.getPageContent(this.pageTitle);
}
this.oldRedirectTarget = this.redirectRegex.exec(this.pageContent)?.[1];
this.oldRedirectTags = Object.entries(this.redirectTemplates)
.map(([name, template]) =>
[name, ...template.aliases].some(alias =>
new RegExp(`{{\\s*[${alias[0].toLowerCase()}${alias[0]}]${alias.slice(1)}\\s*(\\||}})`)
.test(this.pageContent)
) ? name : null
)
.filter(Boolean)
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
// Fix the template parameter extraction regex
const templateMatches = Object.entries(this.redirectTemplates)
.flatMap(([name, template]) =>
[name, ...template.aliases].map(alias => {
const regex = new RegExp(`{{\\s*[${alias[0].toLowerCase()}${alias[0]}]${alias.slice(1)}\\s*\\|?([^}]*?)\\s*}}`);
const match = regex.exec(this.pageContent);
return match ? [name, match[1]] : null;
})
)
.filter(Boolean);
this.oldRedirectTagData = Object.fromEntries(
templateMatches.map(([name, params]) => {
if (!params) return null;
const paramList = params.split('|')
.map((param, index) => {
if (!param.includes('=')) {
return [(index + 1).toString(), param.trim()];
}
const [key, value] = param.split('=');
return [key.trim(), value.trim()];
});
return [name, paramList];
})
.filter(Boolean)
);
this.oldStrayText = [
/{{short description\|.*?}}/i.exec(this.pageContent)?.[0],
/{{DISPLAYTITLE:.*?}}/.exec(this.pageContent)?.[0],
/{{italic title\|?.*?}}/i.exec(this.pageContent)?.[0],
/{{title language\|.*?}}/.exec(this.pageContent)?.[0],
/{{authority control(\|.*?)?}}/i.exec(this.pageContent)?.[0]
].filter(Boolean).join('\n');
if (this.oldRedirectTarget) {
this.redirectInput.setValue(this.oldRedirectTarget.replaceAll('_', ' '));
} else {
mw.notify('[RedirectHelper] 找不到重定向目標!', { type: 'error' });
}
this.tagSelect.setValue(this.oldRedirectTags);
for (const [name, params] of Object.entries(this.oldRedirectTagData)) {
const templateInfo = this.templateEditorsInfo.find(info => info.name === name);
if (templateInfo) {
for (const [paramName, value] of params) {
const param = templateInfo.parameters.find(p => [p.name, ...p.aliases].includes(paramName));
if (param) {
param.editor.setValue(value);
}
}
}
}
this.updateSummary();
}
}
// Main RedirectHelper class
class RedirectHelper {
constructor() {
this.api = new mw.Api({ userAgent: 'RedirectHelper/2.0.0' });
this.redirectTemplates = null;
this.contentText = null;
this.pageTitle = null;
this.pageTitleParsed = null;
this.createdWatchMethod = null;
}
async run() {
if (!this.passesPreChecks()) return;
this.redirectTemplates = await Utils.fetchRedirectTemplates(this.api);
this.contentText = document.querySelector('#mw-content-text');
if (!this.contentText) {
mw.notify('[RedirectHelper] 找不到 #mw-content-text 元素!', { type: 'error' });
return;
}
this.pageTitle = mw.config.get('wgPageName');
this.pageTitleParsed = mw.Title.newFromText(this.pageTitle);
if (!this.pageTitleParsed) {
mw.notify('[RedirectHelper] 無法讀取頁面標題!', { type: 'error' });
return;
}
const configWatchMethod = window.redirectHelperConfiguration?.createdWatchMethod;
this.createdWatchMethod = configWatchMethod && ['nochange', 'preferences', 'unwatch', 'watch'].includes(configWatchMethod)
? configWatchMethod
: 'preferences';
await this.checkPageAndLoad();
}
passesPreChecks() {
return [
mw.config.get('wgNamespaceNumber') >= 0,
mw.config.get('wgIsProbablyEditable'),
['view', 'edit'].includes(mw.config.get('wgAction')),
(mw.config.get('wgRevisionId') || mw.config.get('wgCurRevisionId')) === mw.config.get('wgCurRevisionId'),
!mw.config.get('wgDiffOldId')
].every(Boolean);
}
async checkPageAndLoad() {
mw.util.addCSS(CONFIG.STYLES);
const pageInfo = await this.api.get({
action: 'query',
formatversion: '2',
prop: 'info',
titles: this.pageTitle
});
const config = {
redirectTemplates: this.redirectTemplates,
contentText: this.contentText,
pageTitle: this.pageTitle,
pageTitleParsed: this.pageTitleParsed
};
if (pageInfo.query.pages[0].missing) {
this.setupCreateRedirectButton(config);
} else if (pageInfo.query.pages[0].redirect) {
new RedirectEditor(config, true, this.createdWatchMethod).load();
} else {
this.setupRedirectPortlet(config);
}
}
setupCreateRedirectButton(config) {
const button = new OO.ui.ButtonWidget({
id: 'create-redirect-button',
label: '創建重定向',
icon: 'articleRedirect',
flags: ['progressive']
});
button.on('click', () => {
button.$element[0].remove();
new RedirectEditor(config, false, this.createdWatchMethod).load();
});
this.contentText.prepend(button.$element[0]);
}
setupRedirectPortlet(config) {
const portletId = mw.config.get('skin') === 'minerva' ? 'p-tb' : 'p-cactions';
const portlet = mw.util.addPortletLink(portletId, '#', '編輯重定向', 'redirect-helper');
portlet.addEventListener('click', (event) => {
event.preventDefault();
new RedirectEditor(config, false, this.createdWatchMethod).load();
window.scrollTo({ top: 0, behavior: 'smooth' });
portlet.remove();
});
}
}
// Initialize the helper when required modules are loaded
mw.loader.using(CONFIG.REQUIRED_MODULES, () => {
new RedirectHelper().run();
});
// </nowiki>