// <nowiki>
/*
var _addText="{{工具警告|QuickRedirect}}";
var _addText="{{Notice|<big>'''文檔請見:[[User:Addonian1123/QuickRedirect]]'''</big>}}";
-----
QuickRedirect - [[User:SuperGrey/gadgets/RedirectHelper]]的分支 - CC BY-SA 4.0或GPL-3.0
*/
const userLang = mw.config.get('wgUserLanguage') || mw.config.get('wgUserVariant') || 'zh-hant';
const lowerLang = userLang.toLowerCase();
let langType = 'hant';
if (['zh-hans', 'zh-cn', 'zh-my', 'zh-sg'].some(l => lowerLang.startsWith(l))) {
langType = 'hans';
} else if (lowerLang.startsWith('zh-tw')) {
langType = 'tw';
}
function i18n(hans, hant, tw) {
if (langType === 'hans') return hans;
if (langType === 'tw') return tw || hant;
return hant;
}
function getSysMsg(key, defaultText) {
return (mw.message && mw.message(key).exists()) ? mw.msg(key) : defaultText;
}
function locText(val) {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
if (typeof val !== 'object') return String(val);
const variant = (mw.config.get('wgUserVariant') || mw.config.get('wgUserLanguage') || 'zh-hant').toLowerCase();
if (val[variant]) return val[variant];
let fallbacks = [];
if (variant === 'zh-tw') {
fallbacks = ['zh-tw', 'zh-hant', 'zh-hk', 'zh-mo', 'zh', 'zh-hans', 'zh-cn', 'en'];
} else if (variant === 'zh-hant') {
fallbacks = ['zh-hant', 'zh-tw', 'zh-hk', 'zh-mo', 'zh', 'zh-hans', 'zh-cn', 'en'];
} else if (variant === 'zh-cn') {
fallbacks = ['zh-cn', 'zh-hans', 'zh-sg', 'zh-my', 'zh', 'zh-hant', 'zh-tw', 'en'];
} else if (variant === 'zh-hans') {
fallbacks = ['zh-hans', 'zh-cn', 'zh-sg', 'zh-my', 'zh', 'zh-hant', 'zh-tw', 'en'];
} else if (['zh-hk', 'zh-mo'].includes(variant)) {
fallbacks = [variant, 'zh-hant', 'zh-tw', 'zh', 'zh-hans', 'zh-cn', 'en'];
} else if (['zh-sg', 'zh-my'].includes(variant)) {
fallbacks = [variant, 'zh-hans', 'zh-cn', 'zh', 'zh-hant', 'zh-tw', 'en'];
} else {
fallbacks = ['zh-hant', 'zh-tw', 'zh', 'zh-hans', 'zh-cn', 'en'];
}
for (const l of fallbacks) {
if (val[l]) return val[l];
}
return Object.values(val)[0] || '';
}
const msg = {
quickRedirect: 'QuickRedirect',
close: getSysMsg('parentheses-close', i18n('关闭', '關閉')),
save: getSysMsg('savechanges', i18n('保存更改', '儲存變更')),
cancel: getSysMsg('cancel', i18n('取消', '取消')),
createRedirect: i18n('创建重定向', '创建重定向', '新增重新導向'),
editRedirect: i18n('编辑重定向', '編輯重定向', '編輯重新導向'),
changeToRedirect: i18n('设定重定向', '設定重定向', '設定重新導向'),
targetLabel: i18n('重定向目标:', '重定向目標:', '重新導向目標:'),
targetPlaceholderForward: i18n('目标页面名称', '目標頁面名稱'),
targetPlaceholderReverse: i18n('目标页面名称 (批量请以 | 分隔)', '目標頁面名稱 (批次請以 | 分隔)', '目標頁面名稱 (批次請以 | 分隔)'),
directionLabel: i18n('重定向方向:', '重定向方向:', '重新導向方向:'),
forward: i18n('此页面重定向至目标', '此頁面重定向至目標', '此頁面重新導向至目標'),
reverse: i18n('目标页面重定向至此', '目標頁面重定向至此', '目標頁面重新導向至此'),
categoryLabel: i18n('重定向分类模板:', '重定向分類模板:', '重新導向分類模板:'),
summaryLabel: i18n('编辑摘要:', '編輯摘要:', '編輯摘要:'),
summaryCleanup: i18n('整理重定向', '整理重定向', '整理重新導向'),
summaryFixDouble: i18n('修复双重重定向', '修復雙重重定向', '修復雙重重新導向'),
summaryFixLoop: i18n('修复循环重定向', '修復循環重定向', '修復循環重新導向'),
summaryRemoveBad: i18n('移除误植的重定向分类模板', '移除誤植的重定向分類模板', '移除誤植的重新導向分類模板'),
missingTarget: i18n('请输入重定向目标!', '請輸入重定向目標!', '請輸入重新導向目標!'),
invalidTarget: i18n('无效的重定向目标:$1', '無效的重定向目標:$1', '無效的重新導向目標:$1'),
selfRedirect: i18n('不能将页面重定向至同页!', '不能將頁面重定向至同一頁!', '不能將頁面重新導向至同一頁!'),
forwardMultipleError: i18n('从此页重定向至其他页只能有一个目标!', '從此頁重定向至其他頁面只能有一個目標!', '從此頁面重新導向至其他頁面只能有一個目標!'),
success: i18n('成功!', '成功!'),
error: getSysMsg('error', i18n('发生错误', '發生錯誤')),
checking: i18n('正在检查…', '正在檢查…'),
processing: i18n('正在处理…', '正在處理…'),
removeRedirect: i18n('移除重定向', '移除重定向', '移除重新導向'),
removeConfirmTitle: getSysMsg('confirm', i18n('确认移除', '確認移除')),
removeConfirmMessage: i18n('您确定要移除此页面的重定向与相关模板吗?', '您確定要移除此頁面的重定向與相關模板嗎?', '您確定要移除此頁面的重新導向與相關模板嗎?'),
summaryRemoveRedirect: i18n('移除重定向', '移除重定向', '移除重新導向'),
watchPage: getSysMsg('watchthis', i18n('加入监视列表', '加入監視清單')),
syncTalkPage: i18n('同步讨论页:', '同步討論頁:'),
syncAlways: i18n('永远', '永遠'),
syncIfExists: i18n('仅讨论页存在时', '僅討論頁存在時'),
syncIfMissing: i18n('仅讨论页不存在时', '僅討論頁不存在時'),
syncNever: i18n('永不', '永不'),
templateParameters1: i18n('模板参数', '模板參數'),
templateParameters2: i18n('用于', '用於'),
templateParameters3: i18n('个模板', '個模板'),
templateParametersNoTemplate: i18n('无', '無'),
templateParamsEmpty: i18n('模板参数(无)', '模板參數(無)'),
noTemplateParams: i18n('无模板参数', '無模板參數'),
autoFix: i18n('自动修复(推荐)', '自動修復(建議)'),
doubleRedirectWarning: i18n('警告:目标页面已重定向到“$1”。', '警告:目標頁面已重定向到「$1」。', '警告:目標頁面已重新導向至「$1」。'),
crossNamespaceWarning: i18n('警告:将建立跨空间重定向(从$1至$2)。', '警告:將建立跨空間重定向(從$1至$2)。', '警告:將建立跨空間重新導向(從$1至$2)。'),
targetHasContentWarning: i18n('警告:“$1”已存在非重定向内容,设定重定向将覆盖现有内容!', '警告:「$1」已存在非重定向內容,設定重定向將覆蓋現有內容!', '警告:「$1」已存在非重新導向內容,設定重新導向將覆蓋現有內容!'),
targetIsRedirectWarning: i18n('警告:“$1”目前已重定向至其他页面,继续操作将修改重定向目标。', '警告:「$1」目前已重定向至其他頁面,繼續操作將修改重定向目標。', '警告:「$1」目前已重新導向至其他頁面,繼續操作將修改重新導向目標。'),
batchLimitError5: i18n('错误:为避免此工具遭到滥用,批量修改5个以上页面需要自动确认用户权限。', '錯誤:為避免此工具遭到濫用,批次修改5個以上頁面需要自動確認用戶權限。', '錯誤:為避免此工具遭到濫用,批次修改5個以上頁面需要自動確認使用者權限。'),
batchLimitError10: i18n('错误:为避免此工具遭到滥用,批量修改10个以上页面需要延伸确认用户权限。', '錯誤:為避免此工具遭到濫用,批次修改10個以上頁面需要延伸確認用戶權限。', '錯誤:為避免此工具遭到濫用,批次修改10個以上頁面需要延伸確認使用者權限。'),
batchLimitError100: i18n('错误:为避免此工具遭到滥用,批量修改100个以上页面需要管理员权限。', '錯誤:為避免此工具遭到濫用,批次修改100個以上頁面需要管理員權限。', '錯誤:為避免此工具遭到濫用,批次修改100個以上頁面需要管理員權限。'),
batchLimitError500: i18n('错误:为避免此工具遭到滥用,批量修改的最大值为500个页面。', '錯誤:為避免此工具遭到濫用,批次修改的最大值為500個頁面。', '錯誤:為避免此工具遭到濫用,批次修改的最大值為500個頁面。'),
};
const CONFIG = {
STYLES: `
#create-redirect-button { margin-bottom: 20px }
#quick-redirect-box {
position: relative;
margin-right: auto;
margin-bottom: 25px !important;
margin-left: auto;
background-color: var(--background-color-interactive-subtle, #f8f9fa);
width: 800px;
max-width: calc(100% - 50px);
color: var(--color-base, #202122);
border: 1px solid var(--border-color-base, #a2a9b1);
}
.redirect-input-layout label { font-weight: 700 }
.quick-redirect-warning-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background-color: var(--background-color-warning-subtle, #fdf2d5);
border: 1px solid var(--border-color-warning, #fca652);
color: var(--color-warning, #734500);
padding: 8px 12px;
border-radius: 2px;
margin-top: 8px;
margin-bottom: 8px;
}
.quick-redirect-warning-text {
font-size: 0.9em;
line-height: 1.4;
flex: 1;
white-space: pre-wrap;
}
.redirect-helper-template-parameters-container,
.quick-redirect-template-parameters-container {
margin-top: 10px;
margin-bottom: 10px;
background-color: var(--background-color-interactive-subtle, #f8f9fa);
border: 1px solid var(--border-color-base, #a2a9b1);
padding: 5px;
}
.redirect-helper-template-parameters-container summary,
.quick-redirect-template-parameters-container summary {
cursor: pointer;
font-weight: 700;
}
.redirect-helper-template-parameters-container details,
.quick-redirect-template-parameters-container details {
margin-top: 5px;
margin-bottom: 5px;
background-color: var(--background-color-interactive, #eaecf0);
border: 1px solid var(--border-color-muted, #c8ccd1);
padding: 5px;
}
#redirect-helper-no-templates-message { margin-top: 5px; font-style: italic; }
#quick-redirect-summary-layout {
margin-top: 15px;
border-top: 1px solid var(--border-color-base, gray);
padding-top: 10px;
}
#quick-redirect-submit-layout { margin-top: 10px; display: flex; justify-content: space-between; align-items: center; }
.quick-redirect-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.quick-redirect-left-actions { display: flex; gap: 8px; align-items: center; }
.quick-redirect-inline-watch { margin-bottom: 0 !important; margin-right: 5px; }
.quick-redirect-inline-sync { margin-bottom: 0 !important; }
.quick-redirect-inline-sync .oo-ui-layout .oo-ui-labelElement .oo-ui-fieldLayout .oo-ui-fieldLayout-align-left { width: auto }
.quick-redirect-inline-sync .oo-ui-fieldLayout-header { width: auto !important; padding-right: 6px !important; padding-bottom: 0 !important; }
.quick-redirect-inline-sync .oo-ui-fieldLayout-body { display: flex !important; align-items: center; width: 100% !important; }
.quick-redirect-inline-sync .oo-ui-fieldLayout-field { width: auto !important; }
.quick-redirect-inline-sync .oo-ui-labelElement-label { padding-top: 0px !important; margin-right: 0px !important; }
.quick-redirect-inline-sync .oo-ui-dropdownInputWidget { width: 185px; }
`,
REQUIRED_MODULES: [
'mediawiki.util',
'oojs-ui-core',
'oojs-ui-widgets',
'oojs-ui-windows'
],
SCRIPT_MESSAGE: ' // [[User:Addonian1123/QuickRedirect|QuickRedirect]]'
};
const Utils = {
async fetchRedirectTemplates(api) {
try {
const res = await api.get({
action: "query", formatversion: "2", prop: "revisions", rvprop: "content", rvslots: "main",
titles: "User:Addonian1123/QuickRedirect/categories.json|User:SuperGrey/gadgets/RedirectHelper/categories.json",
});
const pages = res.query.pages.filter(p => !p.missing);
const bestPage = pages.find(p => p.title.includes('Addonian1123')) || pages[0];
return JSON.parse(bestPage?.revisions?.[0]?.slots?.main?.content || "{}");
} catch (e) {
return {};
}
},
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() || '';
}
};
class RedirectInputWidget extends OO.ui.TextInputWidget {
constructor(config, pageTitleParsed) {
super(config);
OO.ui.mixin.LookupElement.call(this, config);
this.api = new mw.Api({ userAgent: 'QuickRedirect/3.0.0' });
this.pageTitleParsed = pageTitleParsed;
this.allowMultiple = false;
}
}
OO.mixinClass(RedirectInputWidget, OO.ui.mixin.LookupElement);
RedirectInputWidget.prototype.getLookupRequest = function () {
const val = this.getValue();
const terms = this.allowMultiple ? val.split(/[\n|,,、]/) : [val];
const lastTerm = terms[terms.length - 1].trim();
if (!lastTerm) {
const deferred = $.Deferred();
deferred.resolve({ query: { search: [] } });
return deferred.promise();
}
return this.api.get({ action: 'query', formatversion: '2', list: 'search', srsearch: lastTerm, srnamespace: '*', srlimit: 10 });
};
RedirectInputWidget.prototype.getLookupCacheDataFromResponse = function (data) { return data?.query?.search ?? []; };
RedirectInputWidget.prototype.getLookupMenuOptionsFromData = function (data) {
return data.map(result => {
const title = mw.Title.newFromText(result.title)?.getPrefixedText() || result.title;
return new OO.ui.MenuOptionWidget({ data: title, label: title });
});
};
RedirectInputWidget.prototype.onLookupMenuItemChoose = function (item) {
if (item) {
if (this.allowMultiple) {
const val = this.getValue();
const terms = val.split(/[\n|,,、]/);
terms.pop();
terms.push(item.getData());
this.setValue(terms.join(' | ') + (terms.length > 0 ? ' | ' : ''));
} else {
this.setValue(item.getData());
}
}
this.closeLookupMenu();
};
class QuickRedirectEditor {
constructor(config, pageState, buttonText, createdWatchMethod) {
this.api = new mw.Api({ userAgent: 'QuickRedirect/3.0.0' });
this.redirectTemplates = config.redirectTemplates;
this.contentText = config.contentText;
this.pageTitle = config.pageTitle;
this.pageTitleParsed = config.pageTitleParsed;
this.pageState = pageState;
this.buttonText = buttonText;
this.createdWatchMethod = createdWatchMethod;
this.redirectRegex = /^\s*#.*?:?\s*\[\[\s*:?([^[\]{\vert{}}]+?)\s*(?:\\Vert{}[^[\]{|}]+?)?\]\]\s*/i;
this.pageContent = '';
this.direction = 'forward';
this.templateEditorsInfo = [];
this.checkTimeout = null;
this.lastRequestId = 0;
}
async load() {
this.editorBox = new OO.ui.PanelLayout({
id: 'quick-redirect-box', padded: true, expanded: false, framed: true
});
this.initializeUIComponents();
await this.loadSubmitElements();
const inputContainer = $('<div>').addClass('input-container');
inputContainer.append(this.redirectInputLayout.$element);
inputContainer.append(this.warningContainer);
if (this.directionLayout) inputContainer.append(this.directionLayout.$element);
inputContainer.append(this.tagSelectLayout.$element, this.abbrInputLayout.$element, this.templateParametersEditor, this.summaryInputLayout.$element);
this.editorBox.$element.append(inputContainer, this.submitLayout.$element);
this.contentText.prepend(this.editorBox.$element[0]);
if (this.pageState !== 'missing') await this.loadExistingData();
}
initializeUIComponents() {
this.redirectInput = new RedirectInputWidget({ placeholder: msg.targetPlaceholderForward, required: true }, this.pageTitleParsed);
this.redirectInput.allowMultiple = false;
this.redirectInput.on('change', () => this.handleInputChange());
this.redirectInputLayout = new OO.ui.FieldLayout(this.redirectInput, { label: msg.targetLabel, align: 'top' });
this.warningLabel = new OO.ui.LabelWidget({ classes: ['quick-redirect-warning-text'] });
this.fixRedirectButton = new OO.ui.ButtonWidget({
label: msg.autoFix, flags: ['warning'], framed: true
});
this.fixRedirectButton.on('click', () => {
if (this.finalTarget) {
this.redirectInput.setValue(this.finalTarget);
this.warningContainer.hide();
}
});
this.warningContainer = $('<div>')
.addClass('quick-redirect-warning-box')
.append(this.warningLabel.$element, this.fixRedirectButton.$element)
.hide();
if (this.pageState !== 'missing') {
this.directionSelect = new OO.ui.RadioSelectWidget({
items: [
new OO.ui.RadioOptionWidget({ data: 'forward', label: msg.forward }),
new OO.ui.RadioOptionWidget({ data: 'reverse', label: msg.reverse })
]
});
this.directionSelect.selectItemByData('forward');
this.directionSelect.on('select', (item) => {
if (item) {
this.direction = item.getData();
if (this.direction === 'reverse') {
this.redirectInput.$input.attr('placeholder', msg.targetPlaceholderReverse);
this.redirectInput.allowMultiple = true;
} else {
this.redirectInput.$input.attr('placeholder', msg.targetPlaceholderForward);
this.redirectInput.allowMultiple = false;
}
this.updateSummary();
this.handleInputChange();
}
});
this.directionLayout = new OO.ui.FieldLayout(this.directionSelect, { label: msg.directionLabel, align: 'top' });
}
const tagOptions = Object.entries(this.redirectTemplates).map(([key, template]) => {
const labelText = locText(template.label) || key;
if (!template.redirect) return { data: key, label: labelText };
const rwpText = i18n(' (具有可能性的重定向)', ' (具有可能性的重新導向)');
const label = new OO.ui.HtmlSnippet(`<span>${labelText}</span><span style="font-style:italic; color:var(--color-subtle, #54595d);">${rwpText}</span>`);
return { data: key, label: label };
});
this.tagSelect = new OO.ui.MenuTagMultiselectWidget({
allowArbitrary: false,
allowReordering: false,
options: tagOptions
});
this.tagSelect.getMenu().filterMode = 'substring';
this.tagSelectLayout = new OO.ui.FieldLayout(this.tagSelect, {
label: msg.categoryLabel,
classes: ['redirect-input-layout'],
align: 'top'
});
this.abbrInput = new OO.ui.TextInputWidget({ placeholder: i18n('原名称(如全称)', '原名稱(如全稱)') });
this.abbrInputLayout = new OO.ui.FieldLayout(this.abbrInput, { label: i18n('缩写原名称:', '縮寫原名稱:'), align: 'top' });
this.abbrInputLayout.toggle(false);
this.templateParametersEditor = document.createElement('details');
this.templateParametersEditor.classList.add('redirect-helper-template-parameters-container');
const summary = document.createElement('summary');
summary.textContent = msg.templateParamsEmpty;
this.templateParametersEditor.append(summary);
for (const [tag, template] of Object.entries(this.redirectTemplates)) {
const parameters = template.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 = locText(template.label) || tag;
details.append(paramSummary);
const editorInfo = {
name: tag,
details: details,
parameters: []
};
for (const [param, config] of parameters) {
const labelText = locText(config.label);
const descText = locText(config.description);
const defaultText = locText(config.default);
const exampleText = locText(config.example);
const typeTranslations = {
'string': i18n('字符串', '字串'),
'number': i18n('数字', '數字'),
'boolean': i18n('布尔值', '布林值'),
'page': i18n('页面', '頁面'),
'user': i18n('用户', '使用者'),
'date': i18n('日期', '日期')
};
const typeText = typeTranslations[config.type] || config.type || i18n('未知', '未知');
const input = new OO.ui.TextInputWidget({
placeholder: defaultText?.toString(),
required: config.required
});
input.on('change', () => {
this.updateSummary();
if(this.submitButton) this.submitButton.setLabel(msg.save);
});
let labelHtml = `<b>${param}</b>`;
if (labelText && param.toLowerCase() !== labelText.toLowerCase()) {
labelHtml += ` (${labelText})`;
}
if (descText) {
labelHtml += ` - ${descText}`;
}
labelHtml += ` <span style="color:var(--color-subtle, #54595d); font-size:0.9em;">(${i18n('类型', '類型')}: ${typeText})</span>`;
if (config.suggested) labelHtml += i18n(' <span style="color:#00af89; font-size:0.9em;">(建议)</span>', ' <span style="color:#00af89; font-size:0.9em;">(建議)</span>');
if (exampleText) labelHtml += ` <span style="color:var(--color-subtle, #54595d); font-size:0.9em;">(${i18n('例', '例')}: "${exampleText}")</span>`;
const layout = new OO.ui.FieldLayout(input, {
label: new OO.ui.HtmlSnippet(labelHtml),
align: 'top'
});
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 = msg.noTemplateParams;
this.templateParametersEditor.append(noTemplatesMessage);
this.tagSelect.on('change', (items) => {
const values = items.map(item => typeof item === 'string' ? item : item.getData());
const sortedValues = [...values].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
if (values.join(';') !== sortedValues.join(';')) {
const lastItem = items.at(-1);
this.tagSelect.reorder(lastItem, sortedValues.indexOf(typeof lastItem === 'string' ? lastItem : lastItem.getData()));
}
this.updateSummary();
if(this.submitButton) this.submitButton.setLabel(msg.save);
this.abbrInputLayout.toggle(values.includes('捷徑重定向'));
for (const info of this.templateEditorsInfo) {
info.details.style.display = 'none';
}
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 =
`${msg.templateParameters1}(${count > 0 ? msg.templateParameters2 + count + msg.templateParameters3 : msg.templateParametersNoTemplate})`;
this.templateParametersEditor.querySelector('#redirect-helper-no-templates-message').style.display =
count > 0 ? 'none' : 'block';
this.handleInputChange();
});
this.summaryInput = new OO.ui.ComboBoxInputWidget({
options: [{ data: msg.summaryCleanup }, { data: msg.summaryFixDouble }, { data: msg.summaryFixLoop }, { data: msg.summaryRemoveBad }]
});
this.summaryInputLayout = new OO.ui.FieldLayout(this.summaryInput, { id: 'quick-redirect-summary-layout', label: msg.summaryLabel, align: 'top' });
}
handleInputChange() {
const val = this.redirectInput.getValue().trim();
this.submitButton?.setDisabled(val.length === 0);
this.updateSummary();
clearTimeout(this.checkTimeout);
if (val.length > 0) {
this.checkTimeout = setTimeout(() => this.checkStatusAndWarnings(val), 400);
} else {
this.warningContainer.hide();
}
}
async checkStatusAndWarnings(targetRaw) {
const requestId = ++this.lastRequestId;
const targets = targetRaw.split(/[\n|,,、]/).map(s => s.trim()).filter(s => s);
if (targets.length === 0) {
this.warningContainer.hide();
return;
}
let warnings = [];
let canAutoFix = false;
let hasBlockingError = false;
this.finalTarget = null;
if (targets.length > 5) {
const userGroups = mw.config.get('wgUserGroups') || [];
const isAutoconfirmed = userGroups.some(group => ['autoconfirmed', 'confirmed'].includes(group));
if (!isAutoconfirmed) {
warnings.push(msg.batchLimitError5);
hasBlockingError = true;
}
}
if (targets.length > 10) {
const userGroups = mw.config.get('wgUserGroups') || [];
const isExtendedconfirmed = userGroups.some(group => ['extendedconfirmed'].includes(group));
if (!isExtendedconfirmed) {
warnings.push(msg.batchLimitError10);
hasBlockingError = true;
}
}
if (targets.length > 100) {
const userGroups = mw.config.get('wgUserGroups') || [];
const isSysop = userGroups.some(group => ['sysop', 'interfaceadmin'].includes(group));
if (!isSysop) {
warnings.push(msg.batchLimitError100);
hasBlockingError = true;
}
}
if (targets.length > 500) {
warnings.push(msg.batchLimitError500);
hasBlockingError = true;
}
// 測跨名字空間重定向
for (const t of targets) {
const parsedTarget = mw.Title.newFromText(t);
if (!parsedTarget) continue;
const sourceMwTitle = this.direction === 'forward' ? this.pageTitleParsed : parsedTarget;
const targetMwTitle = this.direction === 'forward' ? parsedTarget : this.pageTitleParsed;
if (sourceMwTitle && targetMwTitle && sourceMwTitle.getNamespaceId() !== targetMwTitle.getNamespaceId()) {
const getNsName = (titleObj) => {
const nsId = titleObj.getNamespaceId();
if (nsId === 0) return i18n('(主空间)', '(主空間)');
return mw.config.get('wgFormattedNamespaces')[nsId] || `Namespace ${nsId}`;
};
warnings.push(msg.crossNamespaceWarning.replace('$1', getNsName(sourceMwTitle)).replace('$2', getNsName(targetMwTitle)));
break;
}
}
// 檢測頁面狀態(雙重重定向/覆蓋非重定向內容/變更現有重定向)
try {
if (this.direction === 'forward' && targets.length === 1) {
const res = await this.api.get({
action: 'query', formatversion: '2', titles: targets[0], redirects: 1
});
if (requestId !== this.lastRequestId) return;
const redirects = res.query.redirects || [];
if (redirects.length > 0) {
this.finalTarget = redirects[0].to + (redirects[0].tofragment ? `#${redirects[0].tofragment}` : '');
warnings.push(msg.doubleRedirectWarning.replace('$1', this.finalTarget));
canAutoFix = true;
}
} else if (this.direction === 'reverse' && targets.length > 0) {
const res = await this.api.get({
action: 'query', formatversion: '2', prop: 'info', titles: targets.join('|')
});
if (requestId !== this.lastRequestId) return;
const pages = res.query.pages || [];
for (const page of pages) {
if (page.missing) continue;
if (page.redirect) {
warnings.push(msg.targetIsRedirectWarning.replace('$1', page.title));
} else {
warnings.push(msg.targetHasContentWarning.replace('$1', page.title));
}
}
}
} catch (e) {
console.error('Error checking redirect warnings:', e);
}
if (requestId !== this.lastRequestId) return;
if (warnings.length > 0) {
this.warningLabel.setLabel(warnings.join('\n'));
this.fixRedirectButton.toggle(canAutoFix);
this.warningContainer.css('display', 'flex');
} else {
this.warningContainer.hide();
}
this.submitButton?.setDisabled(hasBlockingError);
}
updateSummary() {
const targetRaw = this.redirectInput.getValue();
const targets = targetRaw.split(/[\n|,,、]/).map(s => s.trim()).filter(s => s);
let targetText = targets[0] || '';
if (targets.length > 1) {
targetText = `[[${targets[0]}]] ` + i18n(`等${targets.length}个页面`, `等${targets.length}個頁面`);
} else if (targets.length === 1) {
targetText = `[[${targets[0]}]]`;
}
const placeholderText = this.direction === 'reverse'
? i18n(`将${targetText}重定向至[[${this.pageTitle}]]`, `將${targetText}重定向至[[${this.pageTitle}]]`, `將${targetText}重新導向至[[${this.pageTitle}]]`)
: i18n(`重定向到${targetText}`, `重定向到${targetText}`, `重新導向到${targetText}`);
this.summaryInput.$input.attr('placeholder', placeholderText);
}
handleCancel() {
this.editorBox.$element[0].remove();
if (this.pageState === 'missing' || this.pageState === 'redirect') {
const btn = new OO.ui.ButtonWidget({ id: 'create-redirect-button', label: this.buttonText, icon: 'articleRedirect', flags: ['progressive'] });
btn.on('click', () => { btn.$element[0].remove(); this.load(); });
this.contentText.prepend(btn.$element[0]);
} else {
if (document.getElementById('quick-redirect')) return;
const portletId = mw.config.get('skin') === 'minerva' ? 'p-tb' : 'p-cactions';
const portlet = mw.util.addPortletLink(portletId, '#', this.buttonText, 'quick-redirect');
if (portlet) {
portlet.addEventListener('click', (e) => {
e.preventDefault();
this.load();
window.scrollTo({ top: 0, behavior: 'smooth' });
portlet.remove();
});
}
}
}
async loadSubmitElements() {
this.submitButton = new OO.ui.ButtonWidget({ label: msg.save, disabled: true, flags: ['progressive', 'primary'] });
const cancelBtn = new OO.ui.ButtonWidget({ label: msg.cancel });
this.submitButton.on('click', () => this.handleSubmit());
cancelBtn.on('click', () => this.handleCancel());
this.watchCheckbox = new OO.ui.CheckboxInputWidget({ selected: false });
this.watchLayout = new OO.ui.FieldLayout(this.watchCheckbox, { label: msg.watchPage, align: 'inline' });
this.watchLayout.$element.addClass('quick-redirect-inline-watch');
this.syncDropdown = new OO.ui.DropdownInputWidget({
options: [
{ data: 'never', label: msg.syncNever },
{ data: 'always', label: msg.syncAlways },
{ data: 'if_exists', label: msg.syncIfExists },
{ data: 'if_missing', label: msg.syncIfMissing }
],
value: 'never'
});
this.syncLayout = new OO.ui.FieldLayout(this.syncDropdown, { label: msg.syncTalkPage, align: 'left' });
this.syncLayout.$element.addClass('quick-redirect-inline-sync');
const mainActions = new OO.ui.HorizontalLayout({
items: [this.watchLayout, this.syncLayout, this.submitButton, cancelBtn]
});
mainActions.$element.addClass('quick-redirect-actions');
this.submitLayout = new OO.ui.HorizontalLayout({ id: 'quick-redirect-submit-layout' });
const userGroups = mw.config.get('wgUserGroups') || [];
const isSysOp = userGroups.includes('sysop');
this.removeButton = new OO.ui.ButtonWidget({
label: isSysOp ? i18n('清除', '清除') : msg.removeRedirect,
flags: ['destructive'],
icon: 'trash'
});
this.removeButton.on('click', () => this.handleRemove());
this.removeButton.toggle(false);
let leftActions = this.removeButton;
if (isSysOp) {
this.deleteButton = new OO.ui.ButtonWidget({
label: getSysMsg('delete', i18n('速删', '速刪')),
flags: ['destructive'],
icon: 'trash'
});
this.deleteButton.on('click', () => {
window.location.href = mw.util.getUrl(this.pageTitle, { action: 'delete' });
});
this.deleteButton.toggle(false);
leftActions = new OO.ui.HorizontalLayout({
items: [this.removeButton, this.deleteButton],
classes: ['quick-redirect-left-actions']
});
}
this.submitLayout.addItems([leftActions, mainActions]);
}
async handleRemove() {
const confirmDialog = new OO.ui.MessageDialog();
const windowManager = new OO.ui.WindowManager();
$('body').append(windowManager.$element);
windowManager.addWindows([confirmDialog]);
windowManager.openWindow(confirmDialog, {
title: msg.removeConfirmTitle,
message: msg.removeConfirmMessage,
actions: [
{ action: 'accept', label: i18n('确定', '確定'), flags: ['destructive'] },
{ action: 'reject', label: msg.cancel, flags: 'safe' }
]
}).closed.then(async (data) => {
if (data && data.action === 'accept') {
this.removeButton.setDisabled(true);
let newContent = this.pageContent.replace(this.redirectRegex, '');
newContent = newContent.replace(/{{\s*(?:Redirect category shell|重定向分类壳|重定向分類殼|重定向模板简介|重定向模板簡介)[\s\S]*?}}\s*/gi, '');
Object.keys(this.redirectTemplates).forEach(tag => {
const aliases = this.redirectTemplates[tag].aliases || [];
const names = [tag, ...aliases].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const namePattern = names.join('|');
const regex = new RegExp(`{{\\s*(?:[Tt]emplate:|模板:)?(?:${namePattern})\\s*(?:\\|[^}]*)?}}\\s*`, 'gi');
newContent = newContent.replace(regex, '');
});
newContent = newContent.trim();
const watchChoice = this.watchCheckbox.isSelected() ? 'watch' : 'preferences';
const success = await this.editOrCreate(
this.pageTitle,
newContent,
msg.summaryRemoveRedirect + CONFIG.SCRIPT_MESSAGE,
watchChoice
);
if (success) {
mw.notify(msg.success, { type: 'success' });
setTimeout(() => window.location.reload(), 1000);
} else {
this.removeButton.setDisabled(false);
}
}
windowManager.destroy();
});
}
async editOrCreate(title, content, summary, watchlist) {
try {
return await this.api.edit(title, () => ({ text: content, summary }), { watchlist });
} catch (error) {
if (error === 'nocreate-missing') {
return await this.api.create(title, { summary, watchlist }, content);
}
mw.notify(`${msg.error}: ${error?.error?.info ?? error}`, { type: 'error' });
return null;
}
}
async handleSubmit() {
this.submitButton.setDisabled(true).setLabel(msg.checking);
const targetRaw = this.redirectInput.getValue().trim();
const targetStrings = targetRaw.split(/[\n|,,、]/).map(s => s.trim()).filter(s => s);
if (targetStrings.length === 0) {
mw.notify(msg.missingTarget, { type: 'error' });
this.submitButton.setDisabled(false).setLabel(msg.save);
return;
}
if (this.direction === 'forward' && targetStrings.length > 1) {
mw.notify(msg.forwardMultipleError, { type: 'error' });
this.submitButton.setDisabled(false).setLabel(msg.save);
return;
}
if (targetStrings.length > 500) {
mw.notify(msg.batchLimitError500), { type: 'error' };
this.submitButton.setDisabled(false).setLabel(msg.save);
return;
} else {
if (targetStrings.length > 100) {
const userGroups = mw.config.get('wgUserGroups') || [];
const isSysop = userGroups.some(group => ['sysop', 'interfaceadmin'].includes(group));
if (!isSysop) {
mw.notify(msg.batchLimitError100), { type: 'error' };
this.submitButton.setDisabled(false).setLabel(msg.save);
return;
} else {
if (targetStrings.length > 10) {
const userGroups = mw.config.get('wgUserGroups') || [];
const isExtendedconfirmed = userGroups.some(group => ['extendedconfirmed'].includes(group));
if (!isExtendedconfirmed) {
mw.notify(msg.batchLimitError10), { type: 'error' };
this.submitButton.setDisabled(false).setLabel(msg.save);
return;
} else {
if (targetStrings.length > 5) {
const userGroups = mw.config.get('wgUserGroups') || [];
const isAutoconfirmed = userGroups.some(group => ['autoconfirmed', 'confirmed'].includes(group));
if (!isAutoconfirmed) {
mw.notify(msg.batchLimitError5), { type: 'error' };
this.submitButton.setDisabled(false).setLabel(msg.save);
return;
}
}
}
}
}
}
}
this.submitButton.setLabel(msg.processing);
const tags = this.tagSelect.getValue();
const templateOutput = tags.map(tag => {
let paramStr = '';
const editorInfo = this.templateEditorsInfo.find(info => info.name === tag);
if (editorInfo) {
editorInfo.parameters.forEach(param => {
const val = param.editor.getValue().trim();
if (val) paramStr += `|${param.name}=${val}`;
});
}
if (tag === '捷徑重定向' && !paramStr) {
const abbrVal = this.abbrInput.getValue().trim();
if (abbrVal) paramStr = `|1=${abbrVal}`;
}
return `{{${tag}${paramStr}}}`;
}).join('\n');
const placeholder = this.summaryInput.$input.attr('placeholder') || '';
const summary = (this.summaryInput.getValue() || placeholder) + CONFIG.SCRIPT_MESSAGE;
const watchChoice = this.watchCheckbox.isSelected() ? 'watch' : 'preferences';
const syncChoice = this.syncDropdown.getValue();
let successCount = 0;
for (const t of targetStrings) {
const parsedTarget = mw.Title.newFromText(t);
if (!parsedTarget) {
mw.notify(msg.invalidTarget.replace('$1', t), { type: 'error' });
continue;
}
let editTitle, redirectTargetTitle;
if (this.direction === 'reverse') {
editTitle = parsedTarget.getPrefixedText();
redirectTargetTitle = this.pageTitleParsed.getPrefixedText();
} else {
editTitle = this.pageTitleParsed.getPrefixedText();
redirectTargetTitle = parsedTarget.getPrefixedText();
}
if (editTitle === redirectTargetTitle) {
mw.notify(msg.selfRedirect, { type: 'error' });
continue;
}
const output = `#REDIRECT [[${redirectTargetTitle}]]\n${tags.length > 0 ? `{{Redirect category shell|\n${templateOutput}\n}}` : ''}`;
if (await this.editOrCreate(editTitle, output, summary, watchChoice)) {
successCount++;
if (syncChoice !== 'never') {
const editMwTitle = mw.Title.newFromText(editTitle);
const targetMwTitle = mw.Title.newFromText(redirectTargetTitle);
if (editMwTitle && targetMwTitle && !editMwTitle.isTalkPage() && editMwTitle.canHaveTalkPage() && targetMwTitle.canHaveTalkPage()) {
const editTalk = editMwTitle.getTalkPage().getPrefixedText();
const targetTalk = targetMwTitle.getTalkPage().getPrefixedText();
if (editTalk !== targetTalk) {
const talkInfo = await this.api.get({ action: 'query', formatversion: '2', titles: editTalk });
const talkMissing = !!talkInfo.query.pages[0].missing;
let shouldSync = false;
if (syncChoice === 'always') shouldSync = true;
else if (syncChoice === 'if_exists' && !talkMissing) shouldSync = true;
else if (syncChoice === 'if_missing' && talkMissing) shouldSync = true;
if (shouldSync) {
const talkOutput = `#REDIRECT [[${targetTalk}]]`;
await this.editOrCreate(editTalk, talkOutput, summary, watchChoice);
}
}
}
}
}
}
if (successCount > 0) {
mw.notify(msg.success, { type: 'success' });
setTimeout(() => window.location.reload(), 1000);
} else {
this.submitButton.setDisabled(false).setLabel(msg.save);
}
}
async detectExistingTags() {
let contentToParse = this.pageContent;
contentToParse = contentToParse.replace(/{{\s*(?:Redirect category shell|重定向分类壳|重定向分類殼|重定向模板简介|重定向模板簡介|This is a redirect)\s*\|([\s\S]*?)}}/gi, '$1');
const regex = /{{\s*(?:[Tt]emplate:|模板:)?([^|{}]+)((?:\|[^}]+)*)}}/gi;
let m;
const extracted = [];
const paramMap = {};
while ((m = regex.exec(contentToParse)) !== null) {
const tName = m[1].trim();
const tParam = m[2] ? m[2].trim() : null;
const tNameLower = tName.toLowerCase();
if (!tNameLower.includes('redirect category shell') &&
!tNameLower.includes('this is a redirect') &&
!tNameLower.includes('重定向模板簡介') &&
!tNameLower.includes('重定向分类壳') &&
!tNameLower.includes('重定向模板简介') &&
!tNameLower.includes('重定向分類殼')) {
const prefixed = `Template:${tName}`;
extracted.push(prefixed);
if (tParam) paramMap[prefixed] = tParam;
}
}
if (extracted.length === 0) return;
const uniqueTitles = [...new Set(extracted)].slice(0, 50);
try {
const res = await this.api.get({
action: 'query', formatversion: '2', titles: uniqueTitles.join('|'), redirects: 1
});
const titleMap = {};
uniqueTitles.forEach(t => { titleMap[t] = t; });
(res.query.normalized || []).forEach(n => { if (titleMap[n.from]) titleMap[n.from] = n.to; });
(res.query.redirects || []).forEach(r => {
Object.keys(titleMap).forEach(k => { if (titleMap[k] === r.from) titleMap[k] = r.to; });
});
const validTags = [];
const resolvedParams = {};
const norm = s => s.toLowerCase().replace(/_/g, ' ').trim();
Object.keys(titleMap).forEach(k => {
const resolvedName = titleMap[k].replace(/^Template:/i, '').trim();
const originalName = k.replace(/^Template:/i, '').trim();
const normResolved = norm(resolvedName);
const normOriginal = norm(originalName);
let matchedKey = Object.keys(this.redirectTemplates).find(key => {
const normKey = norm(key);
if (normKey === normResolved || normKey === normOriginal) return true;
const template = this.redirectTemplates[key];
if (template.aliases && Array.isArray(template.aliases)) {
if (template.aliases.some(alias => norm(alias) === normResolved || norm(alias) === normOriginal)) return true;
}
return false;
});
if (matchedKey) {
if (!validTags.includes(matchedKey)) validTags.push(matchedKey);
if (paramMap[k]) {
resolvedParams[matchedKey] = {};
const pList = paramMap[k].split('|').slice(1);
let autoIdx = 1;
pList.forEach(p => {
const eqIdx = p.indexOf('=');
if (eqIdx !== -1) {
resolvedParams[matchedKey][p.substring(0, eqIdx).trim()] = p.substring(eqIdx + 1).trim();
} else {
resolvedParams[matchedKey][autoIdx++] = p.trim();
}
});
}
}
});
if (validTags.length > 0) {
this.tagSelect.setValue(validTags);
validTags.forEach(tag => {
const editorInfo = this.templateEditorsInfo.find(info => info.name === tag);
const params = resolvedParams[tag];
if (params) {
if (tag === '捷徑重定向' && (params['1'] || params['abbr'])) {
this.abbrInput.setValue(params['1'] || params['abbr']);
}
if (editorInfo) {
editorInfo.parameters.forEach(paramConfig => {
let val = params[paramConfig.name];
if (val === undefined && paramConfig.aliases) {
for (const alias of paramConfig.aliases) {
if (params[alias] !== undefined) { val = params[alias]; break; }
}
}
if (val !== undefined) paramConfig.editor.setValue(val);
});
}
}
});
}
} catch (e) {
console.error('Error resolving template redirects:', e);
}
}
async loadExistingData() {
this.pageContent = await Utils.getPageContent(this.api, this.pageTitle);
const oldTarget = this.redirectRegex.exec(this.pageContent)?.[1];
if (oldTarget) this.redirectInput.setValue(oldTarget.replaceAll('_', ' '));
await this.detectExistingTags();
const hasRedirectTemplate = /{{\s*(?:Redirect category shell|重定向分类壳|重定向分類殼|重定向模板简介|重定向模板簡介)[\s\S]*?}}/gi.test(this.pageContent) ||
Object.keys(this.redirectTemplates).some(tag => {
const aliases = this.redirectTemplates[tag].aliases || [];
const names = [tag, ...aliases].map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const namePattern = names.join('|');
return new RegExp(`{{\\s*(?:[Tt]emplate:|模板:)?(?:${namePattern})\\s*(?:\\|[^}]*)?}}`, 'gi').test(this.pageContent);
});
if (this.pageState === 'redirect' || oldTarget || hasRedirectTemplate) {
this.removeButton.toggle(true);
this.removeButton.setDisabled(false);
if (this.deleteButton) {
this.deleteButton.toggle(true);
this.deleteButton.setDisabled(false);
}
} else {
this.removeButton.toggle(true);
this.removeButton.setDisabled(true);
if (this.deleteButton) {
this.deleteButton.toggle(true);
this.deleteButton.setDisabled(true);
}
}
this.updateSummary();
}
}
class QuickRedirectInit {
constructor() { this.api = new mw.Api({ userAgent: 'QuickRedirect/3.0.0' }); }
async run() {
if (!['view', 'edit'].includes(mw.config.get('wgAction')) || !mw.config.get('wgIsProbablyEditable')) return;
mw.util.addCSS(CONFIG.STYLES);
const templates = await Utils.fetchRedirectTemplates(this.api);
const title = mw.config.get('wgPageName');
const parsed = mw.Title.newFromText(title);
const info = await this.api.get({ action: 'query', formatversion: '2', prop: 'info', titles: title });
const exists = !info.query.pages[0].missing;
const isRedirect = info.query.pages[0].redirect || mw.config.get('wgIsRedirect');
let pageState = 'missing';
let buttonText = msg.createRedirect;
if (exists) {
if (isRedirect) {
pageState = 'redirect';
buttonText = msg.editRedirect;
} else {
pageState = 'normal';
buttonText = msg.changeToRedirect;
}
}
const config = { redirectTemplates: templates, contentText: document.querySelector('#mw-content-text'), pageTitle: title, pageTitleParsed: parsed };
if (pageState === 'missing') {
const btn = new OO.ui.ButtonWidget({ id: 'create-redirect-button', label: buttonText, icon: 'articleRedirect', flags: ['progressive'] });
btn.on('click', () => { btn.$element[0].remove(); new QuickRedirectEditor(config, pageState, buttonText, 'preferences').load(); });
config.contentText.prepend(btn.$element[0]);
} else if (pageState === 'redirect') {
new QuickRedirectEditor(config, pageState, buttonText, 'preferences').load();
} else {
const editor = new QuickRedirectEditor(config, pageState, buttonText, 'preferences');
const portletId = mw.config.get('skin') === 'minerva' ? 'p-tb' : 'p-cactions';
const portlet = mw.util.addPortletLink(portletId, '#', buttonText, 'quick-redirect');
if (portlet) {
portlet.addEventListener('click', (e) => {
e.preventDefault();
editor.load();
window.scrollTo({ top: 0, behavior: 'smooth' });
portlet.remove();
});
}
}
}
}
mw.loader.using(CONFIG.REQUIRED_MODULES, () => { new QuickRedirectInit().run(); });
// </nowiki>