MediaWiki:Forum.js
注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。
- Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
- Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
- Microsoft Edge: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください。
// <nowiki>
(()=>{
// 設定の読み込み待機
const checkConfig = () => {
if (!window.mw || !mw.config || !mw.loader) return;
// 設定がない場合のデフォルト値
mw.forum = mw.forum || {};
mw.forum.toppage = mw.forum.toppage || 'KenryoBBS1';
mw.forum.threads = mw.forum.threads || 20;
mw.forum.sticky = mw.forum.sticky || [];
const fedit = mw.util.getParamValue('fedit');
// ページ判定(掲示板ページでなければ終了)
if(!new RegExp(`^${mw.forum.toppage}(|/.*)$`).test(mw.config.get('wgPageName')) || mw.config.get('wgAction') != 'view'){
return;
}
// 編集モード時のユーザーチェック
if(fedit && document.querySelector(`[id="${fedit}-user"]`) && document.querySelector(`[id="${fedit}-user"]`).innerText != mw.config.get('wgUserName')){
return;
}
// UIモジュールの読み込み
mw.loader.load(['mediawiki.ui.button','mediawiki.ui.input','mediawiki.ui.checkbox']);
mw.loader.using(['mediawiki.util', 'mediawiki.user', 'mediawiki.api'], function() {
// 動物リスト
const animals = [
'アライグマ', 'カピバラ', 'キリン', 'ペンギン', 'パンダ',
'ウサギ', 'リス', 'ハリネズミ', 'コアラ', 'ナマケモノ',
'イルカ', 'クジラ', 'サメ', 'タコ', 'イカ',
'フクロウ', 'タカ', 'スズメ', 'ツバメ', 'カラス',
'ライオン', 'トラ', 'ゾウ', 'サイ', 'カバ',
'オオカミ', 'キツネ', 'タヌキ', 'イノシシ', 'シカ'
];
const isAnon = mw.user.isAnon();
const isAdmin = mw.config.get('wgUserGroups').includes('sysop');
// ユーザー名(IPなど)をハッシュ化して動物インデックスに変換
const stringToHash = (str) => {
let hash = 0;
if (!str || str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
};
// ========== トリップ機能 ==========
// SHA-256を使ってトリップコードを生成する
// 入力: 秘密のキー文字列
// 出力: "◆" + Base64の先頭10文字
const computeTrip = async (key) => {
const encoder = new TextEncoder();
const data = encoder.encode(key);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const bytes = new Uint8Array(hashBuffer);
// バイト列をBase64文字列に変換
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
const base64 = btoa(binary);
return '◆' + base64.slice(0, 10);
};
// localStorageから匿名ユーザーの名前設定を読み込む
const loadAnonSettings = () => {
try {
const saved = localStorage.getItem('forum_anon_settings');
return saved ? JSON.parse(saved) : null;
} catch(e) { return null; }
};
// 匿名ユーザーの名前設定をlocalStorageに保存する
const saveAnonSettings = (name, tripKey) => {
try {
localStorage.setItem('forum_anon_settings', JSON.stringify({ name, tripKey }));
} catch(e) {}
};
// ======================================
(async()=>{
if (isAnon) {
// デフォルトの匿名名(IPハッシュベース)
const identifier = mw.user.getName() || mw.user.sessionId();
const index = stringToHash(identifier) % animals.length;
const defaultName = '匿名' + animals[index];
// 保存済み設定があればそれを使う
const saved = loadAnonSettings();
mw.forum.anonDefaultName = defaultName;
mw.forum.anonName = (saved && saved.name) ? saved.name : defaultName;
mw.forum.anonTripKey = (saved && saved.tripKey) ? saved.tripKey : '';
// トリップがあれば計算してusernameに反映
if (mw.forum.anonTripKey) {
const trip = await computeTrip(mw.forum.anonTripKey);
mw.forum.username = mw.forum.anonName + trip;
} else {
mw.forum.username = mw.forum.anonName;
}
} else {
mw.forum.username = mw.config.get('wgUserName');
}
if(!mw.forum.username) {
// フォールバック
try {
const token = await fetch(mw.config.get('wgArticlePath').replace('$1',"特別:トークページ")).then(r=>r.text());
const doc = new DOMParser().parseFromString(token,'text/html');
const title = doc.querySelector('.mw-page-title-main');
if(title) mw.forum.username = title.innerText;
} catch(e) {}
}
// メイン処理の開始
startForum();
})();
// メイン処理関数
function startForum() {
// ========== 名前・トリップ設定UIのHTML ==========
// 匿名ユーザーのみ投稿フォームの上部に表示される
const anonSettingsHtml = isAnon ? `<details id="f-name-settings" style="margin-bottom:.8em;border:1px solid #ccc;padding:.5em;border-radius:3px;">
<summary style="cursor:pointer;font-weight:bold;user-select:none;">▼ 名前・トリップの設定</summary>
<div style="margin-top:.5em;">
<p style="margin:.2em 0 .5em;font-size:.9em;color:#555;">
トリップキーを設定すると、投稿名の後ろに <b>◆記号+固有コード</b> が付き、なりすましを防げます。<br>
キーは他人に見えませんが、同じキーからは常に同じコードが生成されます。
</p>
<label style="display:block;margin-bottom:.4em;">
名前:
<input type="text" id="f-anon-name" class="mw-ui-input" placeholder="例: 名無しさん" style="width:200px;display:inline-block;margin-left:.5em;">
</label>
<label style="display:block;margin-bottom:.4em;">
トリップキー:
<input type="password" id="f-anon-trip" class="mw-ui-input" placeholder="秘密のキー(省略可)" style="width:200px;display:inline-block;margin-left:.5em;" autocomplete="new-password">
</label>
<span id="f-trip-preview" style="font-size:.9em;color:#555;display:block;margin-bottom:.4em;"></span>
<input type="button" id="f-save-name" value="設定を保存" class="mw-ui-button">
</div>
</details>` : '';
// =================================================
const msg = Object.assign({
loading: '<p>読み込み中...</p>',
postform: `<div id="f-form"><h2>投稿${fedit ? `の編集 (<a href="#post-${fedit}")>#${fedit}</a>)` : ''}</h2>${anonSettingsHtml}<div class="mw-ui-checkbox" style="margin-bottom:.3em;${fedit ? 'display:none;' : ''}"><input type="checkbox" class="mw-ui-checkbox" id="f-reply-cb"><label for="f-reply-cb" style="user-select:none;">返信する</label></div><input type="number" id="f-reply" class="mw-ui-input" style="widht:50%;margin-bottom:.5em;"><textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" id="f-post" value="投稿" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset><style>.mw-ui-checkbox:has(#f-reply-cb:checked)+input{display:block;}#f-reply{display:none;}</style></div>`,
postsummary: 'post',
replysummary: 'reply to',
editsummary: 'edit',
deletesummary: 'delete post',
toppage_css: "<style>.f-sticky>td:first-child>a::before{content:'';background-image:url(https://upload.wikimedia.org/wikipedia/commons/a/a5/OOjs_UI_icon_pushPin.svg);width:.8em;height:.8em;margin-right:.2em;display:inline-block;background-size:.8em}#f-loadmore{display:block;margin-left:auto;margin-right:auto;}</style>",
load_more: '<input type="button" value="もっと読み込む" class="mw-ui-button" id="f-loadmore">',
create: '<input type="button" class="mw-ui-button mw-ui-progressive" value="スレッドを作成">',
createform: '<div id="f-form"><label>スレッド名: <input type="text" id="f-threadname" class="mw-ui-input" style="margin-bottom:.5em;"></label><textarea accesskey="," id="wpTextbox1" cols="80" rows="25" class="mw-editfont-monospace"></textarea><input type="button" value="スレッドを作成" id="f-create" class="mw-ui-button mw-ui-progressive" style="margin-top:.5em;"><input type="button" id="f-preview" value="プレビュー" class="mw-ui-button" style="margin: .5em 0 0 .5em;"><fieldset hidden><legend>プレビュー</legend><div id="f-preview-content"></div></fieldset></div>',
createthreadsummary: 'スレッドの作成',
posterror: 'エラー: 投稿できませんでした',
deleteerror: 'エラー: 削除できませんでした',
gotoform: '<a href="#wpTextbox1"><input type="button" value="投稿フォームへ" class="mw-ui-button" id="f-loadmore"></a>',
editlink: `<a href="${(()=>{const url=new URL(location.href);url.searchParams.set('fedit','$1');return url;})()}" style="margin-left:7px;">編集</a>`
}, mw.forum.msg);
const func = {
getthreads: async (cont) => {
return await new mw.Api().get({
action: "query",
format: "json",
list: "allpages",
formatversion: "2",
[(cont ? 'apcontinue' : 'apprefix')]: (cont ?? new mw.Title(mw.forum.toppage).title+'/'),
apnamespace: new mw.Title(mw.forum.toppage).namespace,
apfilterredir: "nonredirects",
aplimit: mw.forum.threads
}).then(async (allpages) => {
return await new mw.Api().get({
action: "parse",
format: "json",
text: `{{#invoke:Thread|main|1=${cont ? '{}' : JSON.stringify(mw.forum.sticky)}|2=${JSON.stringify(allpages.query.allpages)}${cont ? '|3=true' : ''}}}`,
prop: "text",
wrapoutputclass: "",
contentmodel: "wikitext",
formatversion: "2"
}).then((parse) => {
return [parse.parse.text, (allpages.continue ? allpages.continue.apcontinue : undefined)];
});
});
},
preview: function(){
new mw.Api().get({
action: "parse",
format: "json",
text: document.querySelector("#wpTextbox1").value,
prop: "text",
preview: "true",
wrapoutputclass: "",
contentmodel: "wikitext",
disablelimitreport: "1",
formatversion: "2"
}).then((parse) => {
document.querySelector("#f-form > fieldset").hidden = false;
document.querySelector("#f-preview-content").innerHTML = `<div style="white-space:pre;">${parse.parse.text}</div>`;
});
},
deletePost: async (postId) => {
if(!confirm('本当にこの投稿を削除しますか?')) return;
const source = await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text();
const newText = source.replace(new RegExp(`(\\{\\{post\\|(.*?)\\|${postId}\\|(.*?)\\|4=)((.|\n)*?)(}})`), '$1[Deleted]$6');
new mw.Api().postWithToken('csrf', {
action: 'edit',
title: mw.config.get('wgPageName'),
text: newText,
summary: `${msg.deletesummary} #${postId}`,
format: 'json'
}).done(() => {
location.reload();
}).fail(() => {
mw.notify(msg.deleteerror);
});
},
// ========== 名前設定UIの初期化 ==========
initAnonSettings: () => {
if (!isAnon) return;
const nameInput = document.querySelector('#f-anon-name');
const tripInput = document.querySelector('#f-anon-trip');
const preview = document.querySelector('#f-trip-preview');
const saveBtn = document.querySelector('#f-save-name');
if (!nameInput || !tripInput) return;
// 保存済み値をフォームに反映
nameInput.value = mw.forum.anonName;
tripInput.value = mw.forum.anonTripKey;
// プレビュー更新関数
const updatePreview = async () => {
const name = nameInput.value.trim() || mw.forum.anonDefaultName;
const key = tripInput.value;
if (key) {
const trip = await computeTrip(key);
preview.textContent = `投稿名プレビュー: ${name}${trip}`;
} else {
preview.textContent = `投稿名プレビュー: ${name}`;
}
};
nameInput.addEventListener('input', updatePreview);
tripInput.addEventListener('input', updatePreview);
updatePreview(); // 初期表示
// 保存ボタン
saveBtn.onclick = async () => {
const newName = nameInput.value.trim() || mw.forum.anonDefaultName;
const newTripKey = tripInput.value;
saveAnonSettings(newName, newTripKey);
mw.forum.anonName = newName;
mw.forum.anonTripKey = newTripKey;
if (newTripKey) {
const trip = await computeTrip(newTripKey);
mw.forum.username = newName + trip;
} else {
mw.forum.username = newName;
}
mw.notify('名前設定を保存しました。次の投稿から反映されます。');
};
}
// =========================================
};
const mc = document.querySelector('#mw-content-text>.mw-parser-output');
if(!mc) return;
if(mw.config.get('wgPageName') == mw.forum.toppage){
// トップページ処理
if(mw.util.getParamValue('newthread')=='1'){
mc.innerHTML = msg.createform;
document.querySelector("#f-preview").onclick = func.preview;
mw.loader.using('ext.wikiEditor');
document.querySelector('#f-create').onclick = function(){
let content = '';
if(mw.forum.zeroTemplate){
content += `{{post|System|0|{{subst:#timel:Y/m/d H:i}}|4=${mw.forum.zeroTemplate}}}\n`;
}
// スレッド作成者がログイン済みかどうかで anon フラグを付与
const anonParam = isAnon ? '|anon=1' : '';
content += `{{post|${mw.forum.username}|1|{{subst:#timel:Y/m/d H:i}}|4=${document.querySelector('#wpTextbox1').value}${anonParam}}}`;
new mw.Api().postWithToken('csrf', {
action: 'edit',
title: `${mw.forum.toppage}/${document.querySelector('#f-threadname').value}`,
text: content,
summary: msg.createthreadsummary,
format: 'json'
}).done(() => {
location.href = `${mw.config.get('wgArticlePath').replace('$1',mw.forum.toppage)}/${document.querySelector('#f-threadname').value}`;
}).fail(() => {
mw.notify(msg.posterror);
});
};
return;
}
mc.innerHTML = msg.loading;
func.getthreads().then((res) => {
mc.innerHTML = res[0] + msg.toppage_css;
if(res[1]){
document.querySelector('#mw-content-text').innerHTML += msg.load_more;
document.querySelector('#f-loadmore').onclick = function(){
func.getthreads(res[1]).then((mres) => {
document.querySelector('#mw-content-text>.mw-parser-output>table>tbody').innerHTML += mres[0];
if(!mres[1]){
document.querySelector('#f-loadmore').remove();
}
});
};
}
});
const indicators = document.querySelector('.mw-indicators');
if(indicators) {
indicators.innerHTML = msg.create;
indicators.querySelector("input").onclick = function(){
const url=new URL(window.location.href);
url.searchParams.set('newthread','1');
location.href=url;
};
}
}else{
// スレッドページ処理
mc.innerHTML += msg.postform;
document.querySelector("#f-preview").onclick = func.preview;
mw.loader.using('ext.wikiEditor');
const indicators = document.querySelector('.mw-indicators');
if(indicators) indicators.innerHTML = msg.gotoform;
// 名前設定UIを初期化(匿名ユーザーのみ)
func.initAnonSettings();
document.querySelector('#f-post').onclick = (async() => {
document.querySelector('#f-post').disabled = true;
const source = (await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text());
const lp = source.split('{{post|').length;
let summary;
if(fedit) {
summary = `${msg.editsummary} [[${mw.config.get('wgPageName')}#post-${fedit}|#${fedit}]]`;
} else if(document.querySelector('#f-reply-cb').checked) {
const replyToUser = document.querySelector('[id="'+document.querySelector('#f-reply').value+'-user"]').innerText;
summary = `${msg.postsummary} [[${mw.config.get('wgPageName')}#post-${lp}|#${lp}]] ${msg.replysummary} [[User:${replyToUser}|${replyToUser}]]`;
} else {
summary = `${msg.postsummary} [[${mw.config.get('wgPageName')}#post-${lp}|#${lp}]]`;
}
// 匿名ユーザーには |anon=1 を付与してテンプレートでリンクを非表示にする
const anonParam = isAnon ? '|anon=1' : '';
new mw.Api().postWithToken('csrf', {
action: 'edit',
title: mw.config.get('wgPageName'),
[fedit ? 'text' : 'appendtext']: fedit
? source.replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), `{{post|$1|${fedit}|$2|4=${document.querySelector('#wpTextbox1').value}}}`)
: `\n{{post|${mw.forum.username}|${lp}|{{subst:#timel:Y/m/d H:i}}|4=${document.querySelector('#wpTextbox1').value}${document.querySelector('#f-reply-cb').checked ? '|re='+document.querySelector('#f-reply').value : ''}${anonParam}}}`,
summary: summary,
format: 'json'
}).done(() => {
if(fedit){
location.href = `${mw.util.getUrl()}#post-${fedit}`;
}else{
location.reload();
}
}).fail(() => {
mw.notify(msg.posterror);
document.querySelector('#f-post').disabled = false;
});
});
if(fedit){
location.hash = '#f-form';
document.querySelector('#wpTextbox1').focus();
(async()=>{(await (await fetch(mw.config.get('wgScript') + `?title=${mw.config.get('wgPageName')}&action=raw`)).text()).replace(new RegExp(`\\{\\{post\\|(.*?)\\|${fedit}\\|(.*?)\\|4=((.|\n)*?)}}`), (m, u, d, t)=>{document.querySelector('#wpTextbox1').value = t;})})();
}
if (!isAnon) {
document.querySelectorAll('.f-u-' + mw.config.get('wgUserName')).forEach(el => {
const numText = el.querySelector('.f-number-text').innerText;
const postId = numText.replace('#', '');
el.innerHTML += msg.editlink.replace('%241', postId);
});
}
if (isAdmin) {
document.querySelectorAll('.f-header').forEach(header => {
const delSpan = document.createElement('span');
delSpan.style.marginLeft = '10px';
const delLink = document.createElement('a');
delLink.textContent = '削除';
delLink.style.color = '#d33';
delLink.style.cursor = 'pointer';
delLink.onclick = function() {
const numText = header.querySelector('.f-number-text').innerText;
const postId = numText.replace('#', '');
func.deletePost(postId);
};
delSpan.appendChild(delLink);
header.appendChild(delSpan);
});
}
}
} // end startForum
});
};
if (window.mw && window.mw.loader) {
checkConfig();
} else {
window.addEventListener('load', checkConfig);
}
})();
// </nowiki>