//<nowiki>
(function () {
'use strict';
// 設定
const CONFIG = {
editSummary: '履歴保存のため移動',
protectReason: '履歴保存のため保護',
restoreReason: '履歴保存した「[[{logTitle}]]」より転記'
};
const api = new mw.Api();
let lastLogTitle = '';
function formatTimestampToJapanese(timestamp) {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const weekdays = ['日', '月', '火', '水', '木', '金', '土'];
const weekday = weekdays[date.getDay()];
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${year}年${month}月${day}日 (${weekday}) ${hours}:${minutes} (UTC)`;
}
// allowBots関数
function allowBots(text, user = "nanonaBot2") {
if (!new RegExp("\\{\\{\\s*(nobots|bots[^}]*)\\s*\\}\\}", "i").test(text)) return true;
return (new RegExp("\\{\\{\\s*bots\\s*\\|\\s*deny\\s*=\\s*([^}]*,\\s*)*" + user.replace(/([\(\)\*\+\?\.\-\:\!\=\/\^\$])/g, "\\$1") + "\\s*(?=[,\\}])[^}]*\\s*\\}\\}", "i").test(text)) ? false : new RegExp("\\{\\{\\s*((?!nobots)|bots(\\s*\\|\\s*allow\\s*=\\s*((?!none)|([^}]*,\\s*)*" + user.replace(/([\(\)\*\+\?\.\-\:\!\=\/\^\$])/g, "\\$1") + "\\s*(?=[,\\}])[^}]*|all))?|bots\\s*\\|\\s*deny\\s*=\\s*(?!all)[^}]*|bots\\s*\\|\\s*optout=(?!all)[^}]*)\\s*\\}\\}", "i").test(text);
}
function HistoryBotDialog(config) {
HistoryBotDialog.super.call(this, config);
this.initialRedirectTarget = config.initialRedirectTarget || '';
this.logs = [];
}
OO.inheritClass(HistoryBotDialog, OO.ui.ProcessDialog);
HistoryBotDialog.static.name = 'historyBotDialog';
HistoryBotDialog.static.title = '履歴保存';
HistoryBotDialog.static.size = 'large';
HistoryBotDialog.static.actions = [
{
action: 'execute',
label: '実行',
flags: ['primary', 'progressive']
},
{
action: 'cancel',
label: 'キャンセル',
flags: ['safe']
}
];
HistoryBotDialog.prototype.initialize = function () {
HistoryBotDialog.super.prototype.initialize.apply(this, arguments);
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const title = mw.config.get('wgPageName').replace(/_/g, ' ');
this.titlePanel = new OO.ui.PanelLayout({
expanded: false,
framed: true,
padded: true
});
this.logTypeRadio = new OO.ui.RadioSelectWidget({
items: [
new OO.ui.RadioOptionWidget({
data: 'log',
label: 'log'
}),
new OO.ui.RadioOptionWidget({
data: 'history',
label: 'history'
})
]
});
let selectedhistory = false;
if (lastLogTitle.match(/\/history[ _]?\d+$/)) {
this.logTypeRadio.selectItemByData('history');
selectedhistory = true;
} else {
this.logTypeRadio.selectItemByData('log');
}
this.dateStringInput = new OO.ui.TextInputWidget({
value: today,
placeholder: 'yyyyMMdd',
});
this.useCustomTitleCheckbox = new OO.ui.CheckboxInputWidget();
this.customLogTitleInput = new OO.ui.TextInputWidget({
placeholder: 'ページ名を入力',
disabled: true
});
this.logTitlePreview = new OO.ui.LabelWidget({
label: selectedhistory ? `${title}/history${today}` : `${title}/log${today}`
});
this.redirectPanel = new OO.ui.PanelLayout({
expanded: false,
framed: true,
padded: true
});
this.redirectTargetInput = new OO.ui.TextInputWidget({
value: this.initialRedirectTarget,
placeholder: '前回の履歴保存先ページ名を入力',
help: '今回が初保存の場合は空欄のままにしてください'
});
// ログエリア
this.logPanel = new OO.ui.PanelLayout({
expanded: false,
framed: true,
padded: true
});
this.logArea = $('<div>')
.css({
height: '250px',
overflowY: 'auto',
border: '1px solid #a2a9b1',
padding: '12px',
backgroundColor: '#f8f9fa',
fontFamily: 'monospace',
fontSize: '12px',
lineHeight: '1.4'
});
// レイアウト構築
this.titlePanel.$element.append(
$('<h4>').text('移動先タイトル'),
new OO.ui.FieldLayout(this.logTypeRadio, {
label: 'タイトル形式:',
align: 'top'
}).$element,
new OO.ui.FieldLayout(this.dateStringInput, {
label: '日付文字列:',
align: 'top'
}).$element,
new OO.ui.FieldLayout(this.useCustomTitleCheckbox, {
label: 'カスタムタイトルを使用',
align: 'inline'
}).$element,
new OO.ui.FieldLayout(this.customLogTitleInput, {
label: 'カスタムタイトル:',
align: 'top'
}).$element,
$('<div>').css({
marginTop: '15px',
padding: '10px',
backgroundColor: '#f0f8ff',
borderRadius: '3px'
}).append(
$('<strong>').text('移動先: '),
this.logTitlePreview.$element
)
);
this.redirectPanel.$element.append(
$('<h4>').text('前回ページ設定'),
new OO.ui.FieldLayout(this.redirectTargetInput, {
label: '前回の履歴保存先:',
help: '今回が初保存の場合は空欄のままにしてください',
align: 'top'
}).$element
);
this.logPanel.$element.append(
$('<h4>').text('実行ログ'),
this.logArea
);
this.$body.append(
this.titlePanel.$element,
this.redirectPanel.$element,
this.logPanel.$element
);
// イベントリスナー設定
this.setupEvents();
// 初期ログ
this.addLog('履歴保存 準備完了');
};
// ダイアログが開かれた時の処理
HistoryBotDialog.prototype.getSetupProcess = function (data) {
return HistoryBotDialog.super.prototype.getSetupProcess.call(this, data)
.next(function () {
// フォーカスを適切に設定
this.dateStringInput.focus();
}, this);
};
// ダイアログが閉じられた時の処理
HistoryBotDialog.prototype.getTeardownProcess = function (data) {
return HistoryBotDialog.super.prototype.getTeardownProcess.call(this, data)
.next(function () {
if (this.manager && this.manager.$element) {
this.manager.$element.remove();
}
}, this);
};
HistoryBotDialog.prototype.setupEvents = function () {
const self = this;
// ラジオボタンの変更イベント
this.logTypeRadio.on('select', function () {
self.updateLogTitle();
});
// 日付文字列の変更イベント
this.dateStringInput.on('change', function () {
self.updateLogTitle();
});
// カスタムタイトルチェックボックス
this.useCustomTitleCheckbox.on('change', function (checked) {
self.customLogTitleInput.setDisabled(!checked);
self.dateStringInput.setDisabled(checked);
self.logTypeRadio.setDisabled(checked);
if (!checked) {
self.customLogTitleInput.setValue('');
}
self.updateLogTitle();
});
// カスタムタイトル入力イベント
this.customLogTitleInput.on('change', function () {
self.updateLogTitle();
});
};
HistoryBotDialog.prototype.updateLogTitle = function () {
const logType = this.logTypeRadio.findSelectedItem()?.getData() || 'log';
const customTitle = this.customLogTitleInput.getValue().trim();
const dateString = this.dateStringInput.getValue();
const title = mw.config.get('wgPageName').replace(/_/g, ' ');
if (customTitle) {
this.logTitlePreview.setLabel(customTitle);
} else {
this.logTitlePreview.setLabel(`${title}/${logType}${dateString}`);
}
};
HistoryBotDialog.prototype.addLog = function (message, type = 'info') {
const logEntry = $('<div>')
.css({
margin: '2px 0',
padding: '3px 6px',
borderRadius: '3px',
fontSize: '12px',
backgroundColor: type === 'error' ? '#ffeaa7' :
type === 'success' ? '#d4edda' :
type === 'warning' ? '#fff3cd' : '#f8f9fa'
})
.text(`[${new Date().toLocaleTimeString()}] ${message}`);
this.logArea.append(logEntry);
this.logArea.scrollTop(this.logArea[0].scrollHeight);
};
HistoryBotDialog.prototype.getActionProcess = function (action) {
const self = this;
if (action === 'execute') {
return new OO.ui.Process(async function () {
const logType = self.logTypeRadio.findSelectedItem()?.getData() || 'log';
const dateString = self.dateStringInput.getValue().trim();
const customTitle = self.customLogTitleInput.getValue().trim();
const redirectTarget = self.redirectTargetInput.getValue().trim();
const useCustomTitle = self.useCustomTitleCheckbox.isSelected();
if (!useCustomTitle && !dateString) {
self.addLog('日付文字列を入力してください', 'error');
return;
}
if (useCustomTitle && !customTitle) {
self.addLog('カスタムタイトルを入力してください', 'error');
return;
}
const options = {
logType: logType,
dateString: dateString,
customLogTitle: useCustomTitle ? customTitle : null,
redirectTarget: redirectTarget
};
// ボタンを無効化
self.actions.setAbilities({ execute: false });
try {
await self.processHistorySeparation(options);
} finally {
// ボタンを再有効化
self.actions.setAbilities({ execute: true });
}
});
} else if (action === 'cancel') {
return new OO.ui.Process(function () {
self.close({ action: action });
});
}
return HistoryBotDialog.super.prototype.getActionProcess.call(this, action);
};
// メイン処理関数
HistoryBotDialog.prototype.processHistorySeparation = async function (options) {
const title = mw.config.get('wgPageName').replace(/_/g, ' ');
try {
this.addLog(`処理開始: ${title}`);
// 最新版と保護情報を取得
this.addLog('ページ情報を取得中...');
const pageQuery = await api.get({
action: 'query',
prop: 'revisions|pageprops',
titles: title,
rvprop: 'content|timestamp|ids',
rvslots: '*',
ppprop: 'wikibase_item',
formatversion: 2
});
const page = pageQuery.query.pages[0];
if (!page || page.missing) {
throw new Error('ページが見つかりません');
}
const latestText = page.revisions[0].slots.main.content;
const latestTimestamp = page.revisions[0].timestamp;
const formattedTimestamp = formatTimestampToJapanese(latestTimestamp);
this.addLog(`最新版のタイムスタンプ: ${formattedTimestamp}`);
const wikibaseId = page.pageprops?.wikibase_item;
if (!allowBots(latestText, mw.config.get('wgUserName'))) {
this.addLog('Bot拒否テンプレートが設置されています。処理をスキップしました。', 'warning');
return;
}
// 移動先ページ名作成
const logTitle = options.customLogTitle || `${title}/${options.logType}${options.dateString}`;
this.addLog(`移動先: ${logTitle}`);
this.addLog(`ページを移動中...`);
// ページ移動(リダイレクト残す)
await api.postWithToken('csrf', {
action: 'move',
from: title,
to: logTitle,
reason: CONFIG.editSummary,
});
this.addLog('移動先にテンプレートを追加中...');
// 移動先に {{履歴保存}} を挿入し、リダイレクト化
const redirectWikitext =
`#REDIRECT [[${title}]]\n` +
`{{履歴保存|${title}|${formattedTimestamp}${options.redirectTarget ? `|${options.redirectTarget}` : ''}}}`;
await api.edit(logTitle, () => ({
text: redirectWikitext,
summary: '+{{履歴保存}}'
}));
this.addLog('移動先ページを保護中...');
// 移動後ページを保護
await api.postWithToken('csrf', {
action: 'protect',
title: logTitle,
protections: 'edit=sysop',
reason: CONFIG.protectReason,
expiry: 'infinity'
});
this.addLog('元のページに内容を復元中...');
// 元のページに内容復元
await api.edit(title, () => ({
text: latestText,
summary: CONFIG.restoreReason.replace('{logTitle}', logTitle)
}));
// Wikidata再リンク
if (wikibaseId) {
this.addLog('Wikidataの再リンクを実行中...');
const wikidataapi = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
await wikidataapi.postWithToken('csrf', {
action: 'wbsetsitelink',
id: wikibaseId,
linksite: 'jawiki',
linktitle: title,
});
this.addLog(`Wikidataの再リンクが完了: ${wikibaseId}`);
}
this.addLog(`処理完了: ${title}`, 'success');
} catch (err) {
console.error(`エラー: ${title}`, err);
this.addLog(`エラー: ${err.message}`, 'error');
}
};
let windowManager = null;
// 初版を取得してダイアログを開く
async function openDialog() {
const title = mw.config.get('wgPageName').replace(/_/g, ' ');
if (windowManager) {
windowManager.$element.remove();
}
try {
const activeElement = document.activeElement;
if (activeElement && activeElement.blur) {
activeElement.blur();
}
// 初版取得
const firstRevQuery = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvlimit: 1,
rvdir: 'newer',
rvslots: '*',
rvprop: 'content',
formatversion: 2
});
const firstText = firstRevQuery.query.pages[0].revisions[0].slots.main.content;
// 初版がリダイレクトかを判定
const redirectMatch = firstText.match(/^#(?:REDIRECT|転送)[ \t]*\[\[([^\]]*)\]\]/i);
let lastLogTitle0 = '';
if (redirectMatch) {
lastLogTitle0 = redirectMatch[1];
}
if (lastLogTitle0) {
// カテゴリチェック
const catCheck = await api.get({
action: 'query',
prop: 'categories',
titles: lastLogTitle0,
clcategories: 'Category:履歴を分離したページ',
formatversion: 2
});
if (catCheck.query.pages[0]?.categories?.length > 0) {
lastLogTitle = lastLogTitle0;
}
}
// ダイアログを作成して開く
const dialog = new HistoryBotDialog({
initialRedirectTarget: lastLogTitle || '',
});
windowManager = new OO.ui.WindowManager();
$(document.body).append(windowManager.$element);
windowManager.addWindows([dialog]);
// ダイアログを開く前に少し待機
setTimeout(() => {
windowManager.openWindow(dialog);
}, 100);
} catch (err) {
console.error('初版情報の取得に失敗:', err);
mw.notify('初版情報の取得に失敗しました', { type: 'error' });
}
}
// 初期化(管理者権限チェック)
function init() {
if (!mw.config.get('wgUserGroups').includes('sysop') && mw.config.get('wgUserName') !== 'Nanona15dobato') {
console.log('履歴保存JS: 管理者権限が必要です');
return;
}
// ポートレットリンクを追加
mw.util.addPortletLink(
'p-cactions',
'#',
'履歴保存',
'ca-history-separation',
'履歴保存を実行'
);
document.getElementById('ca-history-separation').addEventListener('click', (e) => {
e.preventDefault();
openDialog();
});
}
mw.loader.using([
'oojs-ui-core',
'oojs-ui-windows',
'oojs-ui-widgets',
'mediawiki.api',
'mediawiki.ForeignApi',
'mediawiki.util'
], function () {
if (mw.config.get('wgNamespaceNumber') < 0) return;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
});
})();
//</nowiki>