R42-33K 整理待ちから同じ公開写真の別候補を作成 |
整理待ち操作の初期化と登録後の自動完了を修正 |
||
| (同じ利用者による、間の1版が非表示) | |||
| 1行目: | 1行目: | ||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
if (document.readyState === 'loading') { | |||
document.addEventListener('DOMContentLoaded', init, { once: true }); | |||
} else { init(); } | |||
function init() { | |||
if (mw.config.get('wgNamespaceNumber') === 14 && | |||
mw.config.get('wgTitle') === '出店写真整理待ち') { | |||
const api = new mw.Api(); | |||
const original = document.getElementById('mw-pages'); | |||
if (!original) return; | |||
const result = document.createElement('section'); | |||
result.className = 'stall-photo-draft-groups'; | |||
const heading = document.createElement('h2'); | |||
heading.textContent = '祭りごとの整理待ち'; | |||
const progress = document.createElement('p'); | |||
progress.setAttribute('role', 'status'); | |||
progress.textContent = '整理待ちを読み込み中…'; | |||
result.append(heading, progress); | |||
original.parentNode.insertBefore(result, original); | |||
async function loadGroups() { | |||
const members = []; | |||
let continuation = ''; | |||
do { | |||
const query = { | |||
action: 'query', list: 'categorymembers', | |||
cmtitle: 'Category:出店写真整理待ち', | |||
cmtype: 'page', cmnamespace: 0, | |||
cmprop: 'ids|title|timestamp', cmlimit: 'max', | |||
formatversion: 2 | |||
}; | |||
if (continuation) query.cmcontinue = continuation; | |||
const response = await api.get(query); | |||
members.push.apply(members, response.query.categorymembers); | |||
continuation = response.continue && response.continue.cmcontinue || ''; | |||
if (continuation && members.length >= 2000) { | |||
throw new Error('件数が多いため従来の一覧を表示します'); | |||
} | |||
} while (continuation); | |||
if (!members.length) { | |||
progress.textContent = '整理待ちの写真はありません。'; | |||
original.style.display = 'none'; | |||
return; | |||
} | |||
const records = new Map(); | |||
for (let i = 0; i < members.length; i += 50) { | |||
const batch = members.slice(i, i + 50); | |||
const response = await api.get({ | |||
action: 'query', prop: 'revisions', | |||
rvprop: 'content', rvslots: 'main', | |||
pageids: batch.map(function (member) { return member.pageid; }).join('|'), | |||
formatversion: 2 | |||
}); | |||
response.query.pages.forEach(function (page) { | |||
const content = page.revisions && page.revisions[0] && | |||
page.revisions[0].slots.main.content || ''; | |||
const match = /^\{\{StallPhotoDraft\n\|festival_id=(\d+)\n/.exec(content); | |||
if (match && /\n\|status=pending\n/.test(content)) { | |||
records.set(page.pageid, match[1]); | |||
} | |||
}); | |||
} | |||
if (records.size !== members.length) { | |||
throw new Error('整理待ち記録の取得が不完全です'); | |||
} | |||
const ids = Array.from(new Set(Array.from(records.values()))); | |||
const festivals = new Map(); | |||
for (let i = 0; i < ids.length; i += 50) { | |||
const response = await api.get({ | |||
action: 'query', pageids: ids.slice(i, i + 50).join('|'), | |||
formatversion: 2 | |||
}); | |||
response.query.pages.forEach(function (page) { | |||
if (!page.missing) festivals.set(String(page.pageid), page.title); | |||
}); | |||
} | |||
const groups = new Map(); | |||
members.forEach(function (member) { | |||
const id = records.get(member.pageid); | |||
if (!groups.has(id)) groups.set(id, []); | |||
groups.get(id).push(member); | |||
}); | |||
progress.remove(); | |||
const filter = document.createElement('input'); | |||
filter.type = 'search'; | |||
filter.placeholder = '祭り名または祭りIDで絞り込む'; | |||
filter.setAttribute('aria-label', filter.placeholder); | |||
result.append(filter); | |||
const blocks = []; | |||
Array.from(groups.keys()).sort(function (a, b) { | |||
return (festivals.get(a) || a).localeCompare(festivals.get(b) || b, 'ja'); | |||
}).forEach(function (id) { | |||
const items = groups.get(id); | |||
items.sort(function (a, b) { | |||
return b.timestamp.localeCompare(a.timestamp); | |||
}); | |||
const block = document.createElement('details'); | |||
block.open = true; | |||
block.style.cssText = 'margin:1rem 0;padding:.75rem;border:1px solid #a2a9b1;border-radius:8px'; | |||
const summary = document.createElement('summary'); | |||
const name = festivals.get(id) || '祭りID ' + id; | |||
summary.textContent = name + '(ID ' + id + '・' + items.length + '件)'; | |||
summary.style.cssText = 'cursor:pointer;font-weight:bold'; | |||
const list = document.createElement('ul'); | |||
items.forEach(function (member) { | |||
const row = document.createElement('li'); | |||
const link = document.createElement('a'); | |||
link.href = mw.util.getUrl(member.title); | |||
link.textContent = '写真の整理待ち ' + member.title.split('/').pop().slice(0, 8); | |||
const date = document.createElement('span'); | |||
date.textContent = ' — ' + member.timestamp.slice(0, 10) + '登録'; | |||
row.append(link, date); | |||
list.append(row); | |||
}); | |||
block.append(summary, list); | |||
result.append(block); | |||
blocks.push({ block: block, search: (name + ' ' + id).toLowerCase() }); | |||
}); | |||
filter.addEventListener('input', function () { | |||
const value = filter.value.trim().toLowerCase(); | |||
blocks.forEach(function (entry) { | |||
entry.block.hidden = value !== '' && !entry.search.includes(value); | |||
}); | |||
}); | |||
original.style.display = 'none'; | |||
} | |||
loadGroups().catch(function (error) { | |||
if (!progress.isConnected) result.append(progress); | |||
progress.textContent = '祭り別表示を読み込めませんでした。下の通常一覧をご利用ください。'; | |||
result.title = error.message || String(error); | |||
}); | |||
return; | |||
} | |||
const pendingKey = 'matsuriStallPhotoDraftRegistrationV1'; | |||
function draftData(content) { | |||
const fields = {}; | |||
if (!/^\{\{StallPhotoDraft\s*\n/.test(content)) return null; | |||
['festival_id', 'file', 'status'].forEach(function (field) { | |||
const match = new RegExp('^\\|' + field + '=(.*)$', 'm').exec(content); | |||
if (match) fields[field] = match[1].trim(); | |||
}); | |||
return fields.festival_id && fields.file ? fields : null; | |||
} | |||
async function updateDraft(api, title, status, linkedPlacement, expected) { | |||
const page = await api.get({ | |||
action: 'query', prop: 'revisions', rvprop: 'timestamp|content', | |||
rvslots: 'main', titles: title, formatversion: 2 | |||
}); | |||
const found = page.query.pages[0]; | |||
if (!found.revisions || !found.revisions.length) throw new Error('整理待ちページが見つかりません'); | |||
const rev = found.revisions[0]; | |||
const content = rev.slots.main.content; | |||
const data = draftData(content); | |||
if (!data) throw new Error('整理待ちの形式を確認できません'); | |||
if (expected && (data.file !== expected.file || | |||
data.festival_id !== expected.festival)) { | |||
throw new Error('元の整理待ち記録が変更されています'); | |||
} | |||
if (data.status === status) return data; | |||
if (data.status !== 'pending') throw new Error('整理状態がすでに変更されています'); | |||
const updated = content.replace(/^\|status=pending\s*$/m, '|status=' + status); | |||
if (updated === content) throw new Error('整理状態を更新できません'); | |||
const response = await api.postWithToken('csrf', { | |||
action: 'edit', title: title, basetimestamp: rev.timestamp, | |||
text: updated, summary: linkedPlacement | |||
? '出店情報 ' + linkedPlacement + ' の登録確認により整理待ちを完了' | |||
: '屋台写真の整理状態を更新' | |||
}); | |||
if (!response.edit || response.edit.result !== 'Success') { | |||
throw new Error('保存応答を確認できません'); | |||
} | |||
return data; | |||
} | |||
async function finishAfterRegistration() { | |||
const pageName = String(mw.config.get('wgPageName') || '').replace(/_/g, ' '); | |||
if (!pageName.startsWith('出店情報/') || mw.config.get('wgAction') !== 'view') return; | |||
const raw = sessionStorage.getItem(pendingKey); | |||
if (!raw) return; | |||
let pending; | |||
try { pending = JSON.parse(raw); } catch (e) { sessionStorage.removeItem(pendingKey); return; } | |||
if (!pending || !/^出店写真整理\/[0-9a-f-]{36}$/i.test(pending.title) || | |||
!/^Matsuri_stall_batch_[0-9a-f-]{36}\.jpg$/i.test(pending.file) || | |||
!/^\d+$/.test(pending.festival) || !Number.isFinite(pending.started)) return; | |||
const api = new mw.Api(); | |||
const placement = await api.get({ | |||
action: 'query', prop: 'revisions', rvprop: 'timestamp|content', | |||
rvslots: 'main', titles: pageName, formatversion: 2 | |||
}); | |||
const found = placement.query.pages[0]; | |||
if (!found.revisions || !found.revisions.length) return; | |||
const rev = found.revisions[0]; | |||
const content = rev.slots.main.content; | |||
const field = function (name) { | |||
const match = new RegExp('^\\|' + name + '=(.*)$', 'm').exec(content); | |||
return match ? match[1].trim() : ''; | |||
}; | |||
if (!/^\{\{FestivalStallPlacement\s*\n/.test(content) || | |||
field('festival_id') !== pending.festival || | |||
field('main_image').replace(/^(?:File:|ファイル:)/, '') !== pending.file || | |||
Date.parse(rev.timestamp) < pending.started - 30000) return; | |||
await updateDraft(api, pending.title, 'done', pageName, pending); | |||
sessionStorage.removeItem(pendingKey); | |||
const banner = document.createElement('p'); | |||
banner.setAttribute('role', 'status'); | |||
banner.textContent = '写真の整理待ちを登録完了に更新しました。'; | |||
(document.querySelector('#mw-content-text') || document.body).prepend(banner); | |||
} | |||
finishAfterRegistration().catch(function (error) { | |||
const banner = document.createElement('p'); | |||
banner.setAttribute('role', 'alert'); | |||
banner.textContent = '出店情報は保存されています。写真の整理待ちの自動更新に失敗しました:' + | |||
(error.message || String(error)) + '。整理待ちページで手動更新してください。'; | |||
(document.querySelector('#mw-content-text') || document.body).prepend(banner); | |||
}); | |||
if (mw.config.get('wgPageName').startsWith('出店写真整理/')) { | if (mw.config.get('wgPageName').startsWith('出店写真整理/')) { | ||
const actions = document.getElementById('stall-photo-draft-actions'); | const actions = document.getElementById('stall-photo-draft-actions'); | ||
if (!actions) return; | if (!actions) return; | ||
const api = new mw.Api(); | const api = new mw.Api(); | ||
const form = Array.from(document.querySelectorAll('form')).find(function (entry) { | |||
return entry.querySelector('input[name="FestivalStallPlacement[main_image]"]'); | |||
}); | |||
if (form) form.addEventListener('submit', function () { | |||
const file = form.querySelector('input[name="FestivalStallPlacement[main_image]"]').value; | |||
const festival = form.querySelector('input[name="FestivalStallPlacement[festival_id]"]').value; | |||
sessionStorage.setItem(pendingKey, JSON.stringify({ | |||
title: String(mw.config.get('wgPageName')).replace(/_/g, ' '), | |||
file: file, festival: festival, started: Date.now() | |||
})); | |||
}); | |||
['done', 'discarded'].forEach(function (status) { | ['done', 'discarded'].forEach(function (status) { | ||
const button = document.createElement('button'); | const button = document.createElement('button'); | ||
| 10行目: | 234行目: | ||
button.textContent = status === 'done' ? '出店情報への登録完了' : 'この整理待ちを取り消す'; | button.textContent = status === 'done' ? '出店情報への登録完了' : 'この整理待ちを取り消す'; | ||
button.addEventListener('click', async function () { | button.addEventListener('click', async function () { | ||
if (!confirm('整理待ちの状態を変更します。公開済みの画像は削除されません。続けますか?')) return; | if (!window.confirm('整理待ちの状態を変更します。公開済みの画像は削除されません。続けますか?')) return; | ||
button.disabled = true; | button.disabled = true; | ||
try { | try { | ||
await updateDraft(api, mw.config.get('wgPageName'), status, ''); | |||
sessionStorage.removeItem(pendingKey); | |||
location.reload(); | location.reload(); | ||
} catch (e) { | } catch (e) { | ||
button.disabled = false; | button.disabled = false; | ||
alert('更新できませんでした:' + (e.message || String(e))); | window.alert('更新できませんでした:' + (e.message || String(e))); | ||
} | } | ||
}); | }); | ||
| 177行目: | 390行目: | ||
picker.disabled = busy; | picker.disabled = busy; | ||
save.disabled = busy || !state || (state.uploaded && !state.needsDraft); | save.disabled = busy || !state || (state.uploaded && !state.needsDraft); | ||
skip.disabled = busy || !state; | skip.disabled = busy || !state || !!state.needsDraft; | ||
skip.textContent = state && state.uploaded && !state.needsDraft | |||
? '保存済み・次の写真へ' | |||
: 'この写真は保存せず次へ'; | |||
blur.disabled = busy || !state || !!state.uploaded || !state.regions.length; | blur.disabled = busy || !state || !!state.uploaded || !state.regions.length; | ||
clear.disabled = busy || !state || !!state.uploaded || !state.regions.length; | clear.disabled = busy || !state || !!state.uploaded || !state.regions.length; | ||
| 418行目: | 634行目: | ||
const dangling = sessionStorage.getItem('matsuriBatchUnlinkedFile'); | const dangling = sessionStorage.getItem('matsuriBatchUnlinkedFile'); | ||
if (dangling) status.textContent = '前回、画像の公開後に整理待ちへの保存が中断した可能性があります。ファイル名:' + dangling + '。重複アップロードせず管理者に確認してください。'; | if (dangling) status.textContent = '前回、画像の公開後に整理待ちへの保存が中断した可能性があります。ファイル名:' + dangling + '。重複アップロードせず管理者に確認してください。'; | ||
} | |||
}()); | }()); | ||
2026年10月1日 (木) 11:32時点における最新版
(function () {
'use strict';
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else { init(); }
function init() {
if (mw.config.get('wgNamespaceNumber') === 14 &&
mw.config.get('wgTitle') === '出店写真整理待ち') {
const api = new mw.Api();
const original = document.getElementById('mw-pages');
if (!original) return;
const result = document.createElement('section');
result.className = 'stall-photo-draft-groups';
const heading = document.createElement('h2');
heading.textContent = '祭りごとの整理待ち';
const progress = document.createElement('p');
progress.setAttribute('role', 'status');
progress.textContent = '整理待ちを読み込み中…';
result.append(heading, progress);
original.parentNode.insertBefore(result, original);
async function loadGroups() {
const members = [];
let continuation = '';
do {
const query = {
action: 'query', list: 'categorymembers',
cmtitle: 'Category:出店写真整理待ち',
cmtype: 'page', cmnamespace: 0,
cmprop: 'ids|title|timestamp', cmlimit: 'max',
formatversion: 2
};
if (continuation) query.cmcontinue = continuation;
const response = await api.get(query);
members.push.apply(members, response.query.categorymembers);
continuation = response.continue && response.continue.cmcontinue || '';
if (continuation && members.length >= 2000) {
throw new Error('件数が多いため従来の一覧を表示します');
}
} while (continuation);
if (!members.length) {
progress.textContent = '整理待ちの写真はありません。';
original.style.display = 'none';
return;
}
const records = new Map();
for (let i = 0; i < members.length; i += 50) {
const batch = members.slice(i, i + 50);
const response = await api.get({
action: 'query', prop: 'revisions',
rvprop: 'content', rvslots: 'main',
pageids: batch.map(function (member) { return member.pageid; }).join('|'),
formatversion: 2
});
response.query.pages.forEach(function (page) {
const content = page.revisions && page.revisions[0] &&
page.revisions[0].slots.main.content || '';
const match = /^\{\{StallPhotoDraft\n\|festival_id=(\d+)\n/.exec(content);
if (match && /\n\|status=pending\n/.test(content)) {
records.set(page.pageid, match[1]);
}
});
}
if (records.size !== members.length) {
throw new Error('整理待ち記録の取得が不完全です');
}
const ids = Array.from(new Set(Array.from(records.values())));
const festivals = new Map();
for (let i = 0; i < ids.length; i += 50) {
const response = await api.get({
action: 'query', pageids: ids.slice(i, i + 50).join('|'),
formatversion: 2
});
response.query.pages.forEach(function (page) {
if (!page.missing) festivals.set(String(page.pageid), page.title);
});
}
const groups = new Map();
members.forEach(function (member) {
const id = records.get(member.pageid);
if (!groups.has(id)) groups.set(id, []);
groups.get(id).push(member);
});
progress.remove();
const filter = document.createElement('input');
filter.type = 'search';
filter.placeholder = '祭り名または祭りIDで絞り込む';
filter.setAttribute('aria-label', filter.placeholder);
result.append(filter);
const blocks = [];
Array.from(groups.keys()).sort(function (a, b) {
return (festivals.get(a) || a).localeCompare(festivals.get(b) || b, 'ja');
}).forEach(function (id) {
const items = groups.get(id);
items.sort(function (a, b) {
return b.timestamp.localeCompare(a.timestamp);
});
const block = document.createElement('details');
block.open = true;
block.style.cssText = 'margin:1rem 0;padding:.75rem;border:1px solid #a2a9b1;border-radius:8px';
const summary = document.createElement('summary');
const name = festivals.get(id) || '祭りID ' + id;
summary.textContent = name + '(ID ' + id + '・' + items.length + '件)';
summary.style.cssText = 'cursor:pointer;font-weight:bold';
const list = document.createElement('ul');
items.forEach(function (member) {
const row = document.createElement('li');
const link = document.createElement('a');
link.href = mw.util.getUrl(member.title);
link.textContent = '写真の整理待ち ' + member.title.split('/').pop().slice(0, 8);
const date = document.createElement('span');
date.textContent = ' — ' + member.timestamp.slice(0, 10) + '登録';
row.append(link, date);
list.append(row);
});
block.append(summary, list);
result.append(block);
blocks.push({ block: block, search: (name + ' ' + id).toLowerCase() });
});
filter.addEventListener('input', function () {
const value = filter.value.trim().toLowerCase();
blocks.forEach(function (entry) {
entry.block.hidden = value !== '' && !entry.search.includes(value);
});
});
original.style.display = 'none';
}
loadGroups().catch(function (error) {
if (!progress.isConnected) result.append(progress);
progress.textContent = '祭り別表示を読み込めませんでした。下の通常一覧をご利用ください。';
result.title = error.message || String(error);
});
return;
}
const pendingKey = 'matsuriStallPhotoDraftRegistrationV1';
function draftData(content) {
const fields = {};
if (!/^\{\{StallPhotoDraft\s*\n/.test(content)) return null;
['festival_id', 'file', 'status'].forEach(function (field) {
const match = new RegExp('^\\|' + field + '=(.*)$', 'm').exec(content);
if (match) fields[field] = match[1].trim();
});
return fields.festival_id && fields.file ? fields : null;
}
async function updateDraft(api, title, status, linkedPlacement, expected) {
const page = await api.get({
action: 'query', prop: 'revisions', rvprop: 'timestamp|content',
rvslots: 'main', titles: title, formatversion: 2
});
const found = page.query.pages[0];
if (!found.revisions || !found.revisions.length) throw new Error('整理待ちページが見つかりません');
const rev = found.revisions[0];
const content = rev.slots.main.content;
const data = draftData(content);
if (!data) throw new Error('整理待ちの形式を確認できません');
if (expected && (data.file !== expected.file ||
data.festival_id !== expected.festival)) {
throw new Error('元の整理待ち記録が変更されています');
}
if (data.status === status) return data;
if (data.status !== 'pending') throw new Error('整理状態がすでに変更されています');
const updated = content.replace(/^\|status=pending\s*$/m, '|status=' + status);
if (updated === content) throw new Error('整理状態を更新できません');
const response = await api.postWithToken('csrf', {
action: 'edit', title: title, basetimestamp: rev.timestamp,
text: updated, summary: linkedPlacement
? '出店情報 ' + linkedPlacement + ' の登録確認により整理待ちを完了'
: '屋台写真の整理状態を更新'
});
if (!response.edit || response.edit.result !== 'Success') {
throw new Error('保存応答を確認できません');
}
return data;
}
async function finishAfterRegistration() {
const pageName = String(mw.config.get('wgPageName') || '').replace(/_/g, ' ');
if (!pageName.startsWith('出店情報/') || mw.config.get('wgAction') !== 'view') return;
const raw = sessionStorage.getItem(pendingKey);
if (!raw) return;
let pending;
try { pending = JSON.parse(raw); } catch (e) { sessionStorage.removeItem(pendingKey); return; }
if (!pending || !/^出店写真整理\/[0-9a-f-]{36}$/i.test(pending.title) ||
!/^Matsuri_stall_batch_[0-9a-f-]{36}\.jpg$/i.test(pending.file) ||
!/^\d+$/.test(pending.festival) || !Number.isFinite(pending.started)) return;
const api = new mw.Api();
const placement = await api.get({
action: 'query', prop: 'revisions', rvprop: 'timestamp|content',
rvslots: 'main', titles: pageName, formatversion: 2
});
const found = placement.query.pages[0];
if (!found.revisions || !found.revisions.length) return;
const rev = found.revisions[0];
const content = rev.slots.main.content;
const field = function (name) {
const match = new RegExp('^\\|' + name + '=(.*)$', 'm').exec(content);
return match ? match[1].trim() : '';
};
if (!/^\{\{FestivalStallPlacement\s*\n/.test(content) ||
field('festival_id') !== pending.festival ||
field('main_image').replace(/^(?:File:|ファイル:)/, '') !== pending.file ||
Date.parse(rev.timestamp) < pending.started - 30000) return;
await updateDraft(api, pending.title, 'done', pageName, pending);
sessionStorage.removeItem(pendingKey);
const banner = document.createElement('p');
banner.setAttribute('role', 'status');
banner.textContent = '写真の整理待ちを登録完了に更新しました。';
(document.querySelector('#mw-content-text') || document.body).prepend(banner);
}
finishAfterRegistration().catch(function (error) {
const banner = document.createElement('p');
banner.setAttribute('role', 'alert');
banner.textContent = '出店情報は保存されています。写真の整理待ちの自動更新に失敗しました:' +
(error.message || String(error)) + '。整理待ちページで手動更新してください。';
(document.querySelector('#mw-content-text') || document.body).prepend(banner);
});
if (mw.config.get('wgPageName').startsWith('出店写真整理/')) {
const actions = document.getElementById('stall-photo-draft-actions');
if (!actions) return;
const api = new mw.Api();
const form = Array.from(document.querySelectorAll('form')).find(function (entry) {
return entry.querySelector('input[name="FestivalStallPlacement[main_image]"]');
});
if (form) form.addEventListener('submit', function () {
const file = form.querySelector('input[name="FestivalStallPlacement[main_image]"]').value;
const festival = form.querySelector('input[name="FestivalStallPlacement[festival_id]"]').value;
sessionStorage.setItem(pendingKey, JSON.stringify({
title: String(mw.config.get('wgPageName')).replace(/_/g, ' '),
file: file, festival: festival, started: Date.now()
}));
});
['done', 'discarded'].forEach(function (status) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = status === 'done' ? '出店情報への登録完了' : 'この整理待ちを取り消す';
button.addEventListener('click', async function () {
if (!window.confirm('整理待ちの状態を変更します。公開済みの画像は削除されません。続けますか?')) return;
button.disabled = true;
try {
await updateDraft(api, mw.config.get('wgPageName'), status, '');
sessionStorage.removeItem(pendingKey);
location.reload();
} catch (e) {
button.disabled = false;
window.alert('更新できませんでした:' + (e.message || String(e)));
}
});
actions.append(button, document.createTextNode(' '));
});
const duplicateButton = document.createElement('button');
duplicateButton.type = 'button';
duplicateButton.textContent = '同じ写真から別の出店候補を作る';
duplicateButton.addEventListener('click', async function () {
duplicateButton.disabled = true;
try {
const page = await api.get({
action: 'query', prop: 'revisions', rvprop: 'content',
rvslots: 'main', titles: mw.config.get('wgPageName'),
formatversion: 2
});
const rev = page.query.pages[0].revisions[0];
const content = rev.slots.main.content;
const draft = /^\{\{StallPhotoDraft\n\|festival_id=(\d+)\n\|year=(\d{4})\n\|file=(Matsuri_stall_batch_[0-9a-f-]{36}\.jpg)\n\|latitude=(-?\d*(?:\.\d+)?)\n\|longitude=(-?\d*(?:\.\d+)?)\n\|status=pending\n\}\}\s*$/i.exec(content);
if (!draft) throw new Error('元の整理待ち記録を確認できません。管理者に確認してください');
const title = '出店写真整理/' + crypto.randomUUID();
await api.postWithToken('csrf', {
action: 'edit', title: title, text: content.trim(),
createonly: 1,
summary: '公開済みの同じ屋台写真から別の出店候補を作成'
});
location.href = mw.util.getUrl(title);
} catch (e) {
duplicateButton.disabled = false;
alert('候補を作成できませんでした:' + (e.message || String(e)));
}
});
actions.append(duplicateButton);
api.get({
action: 'query', prop: 'revisions', rvprop: 'content',
rvslots: 'main', titles: mw.config.get('wgPageName'),
formatversion: 2
}).then(function (page) {
const content = page.query.pages[0].revisions[0].slots.main.content;
const id = /^\{\{StallPhotoDraft\n\|festival_id=(\d+)\n/.exec(content);
if (!id) return;
const nextLink = document.createElement('a');
nextLink.href = mw.util.getUrl('屋台写真一括登録', { festival_id: id[1] });
nextLink.textContent = '別の写真を追加する(祭りIDを引き継ぐ)';
actions.append(document.createTextNode(' '), nextLink);
}).catch(function () {
// The saved draft remains usable even if the extra link cannot be loaded.
});
return;
}
if (mw.config.get('wgPageName') !== '屋台写真一括登録') return;
const root = document.getElementById('stall-photo-batch');
const safety = window.matsuriBatchPhotoSafety;
if (!root || !safety) return;
const api = new mw.Api();
const params = new URLSearchParams(location.search);
const festivalInput = document.createElement('input');
festivalInput.type = 'number';
festivalInput.min = '1';
festivalInput.value = params.get('festival_id') || '';
const yearInput = document.createElement('input');
yearInput.type = 'number';
yearInput.min = '2000';
yearInput.max = '2100';
yearInput.value = String(new Date().getFullYear());
const picker = document.createElement('input');
picker.type = 'file';
picker.accept = 'image/jpeg,image/png,image/webp';
picker.multiple = true;
const status = document.createElement('p');
status.setAttribute('role', 'status');
const preview = document.createElement('img');
preview.alt = '公開前の写真。ぼかす範囲をドラッグしてください';
preview.style.cssText = 'display:none;max-width:100%;max-height:600px;cursor:crosshair;touch-action:none;user-select:none';
preview.draggable = false;
const viewport = document.createElement('div');
viewport.style.cssText = 'max-width:100%;max-height:75vh;overflow:auto';
const previewFrame = document.createElement('div');
previewFrame.style.cssText = 'position:relative;display:inline-block;line-height:0';
const marks = document.createElement('div');
marks.style.cssText = 'position:absolute;inset:0;pointer-events:none';
previewFrame.append(preview, marks);
viewport.append(previewFrame);
const zoom = button('写真を拡大', function () {
const enlarged = preview.dataset.enlarged !== 'true';
preview.dataset.enlarged = String(enlarged);
preview.style.maxWidth = enlarged ? 'none' : '100%';
preview.style.maxHeight = enlarged ? 'none' : '600px';
preview.style.width = enlarged ? '900px' : 'auto';
zoom.textContent = enlarged ? '写真を縮小' : '写真を拡大';
});
const gps = document.createElement('label');
const useGps = document.createElement('input');
useGps.type = 'checkbox';
gps.append(useGps, document.createTextNode(' 写真のGPSをこの出店候補の位置に使う'));
const confirm = document.createElement('label');
const confirmed = document.createElement('input');
confirmed.type = 'checkbox';
confirm.append(confirmed, document.createTextNode(' 写真を確認し、写り込んだ人・個人情報を必要に応じてぼかしました。画像が公開されることを理解しました'));
const blur = button('選択した箇所をぼかす', applyMosaic);
const clear = button('ぼかし指定を解除', function () {
if (state) { state.regions = []; confirmed.checked = false; showPreview(state.base.blob); }
});
const save = button('確認してアップロード・整理待ちに保存', upload);
const skip = button('この写真を飛ばす', next);
const copy = button('同じ写真から別の出店候補を作る', duplicate);
const links = document.createElement('ul');
root.append(
label('祭りページID', festivalInput),
label('開催年', yearInput),
label('写真を複数選択(1枚ずつ確認します)', picker),
status, zoom, viewport, gps, document.createElement('br'),
confirm, document.createElement('br'), blur, clear, save, skip,
document.createElement('br'), copy, links
);
let queue = [];
let index = 0;
let state = null;
let previewUrl = '';
let busy = false;
let drag = null;
copy.disabled = true;
save.disabled = true;
blur.disabled = true;
clear.disabled = true;
function label(title, control) {
const el = document.createElement('label');
el.textContent = title + ':';
el.append(control, document.createElement('br'));
return el;
}
function button(title, fn) {
const el = document.createElement('button');
el.type = 'button';
el.textContent = title;
el.addEventListener('click', function () {
Promise.resolve().then(fn).catch(function (e) {
status.textContent = '失敗しました。再試行できます:' + (e.message || String(e));
busy = false;
controls();
});
});
return el;
}
function controls() {
picker.disabled = busy;
save.disabled = busy || !state || (state.uploaded && !state.needsDraft);
skip.disabled = busy || !state || !!state.needsDraft;
skip.textContent = state && state.uploaded && !state.needsDraft
? '保存済み・次の写真へ'
: 'この写真は保存せず次へ';
blur.disabled = busy || !state || !!state.uploaded || !state.regions.length;
clear.disabled = busy || !state || !!state.uploaded || !state.regions.length;
copy.disabled = busy || !state || !state.uploaded || state.needsDraft;
}
function showPreview(blob) {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
preview.src = previewUrl;
preview.style.display = 'block';
renderMarks();
}
function renderMarks() {
marks.replaceChildren();
if (!state) return;
state.regions.concat(drag && drag.region ? [drag.region] : []).forEach(function (region, index) {
const mark = document.createElement('span');
mark.style.cssText = 'position:absolute;box-sizing:border-box;border:3px solid #e33;background:rgba(255,255,0,.25)';
mark.style.left = (region.x * 100) + '%';
mark.style.top = (region.y * 100) + '%';
mark.style.width = (region.width * 100) + '%';
mark.style.height = (region.height * 100) + '%';
mark.setAttribute('aria-label', 'ぼかし指定 ' + (index + 1));
marks.append(mark);
});
}
function fail(message) { throw new Error(message); }
picker.addEventListener('change', function () {
if (busy) return;
queue = Array.from(picker.files).slice(0, 30);
if (picker.files.length > 30) {
status.textContent = '一度に選べるのは30枚までです。';
}
index = 0;
prepare().catch(function (e) { status.textContent = e.message; busy = false; controls(); });
});
async function prepare() {
state = null;
drag = null;
confirmed.checked = false;
useGps.checked = false;
if (!queue[index]) {
status.textContent = '選択した写真の確認が終わりました。';
preview.style.display = 'none';
controls();
return;
}
busy = true;
controls();
status.textContent = (index + 1) + '/' + queue.length + '枚目をブラウザ内で処理中…';
try {
const prepared = await safety.prepare(queue[index]);
state = {
base: prepared.safe,
blob: prepared.safe.blob,
gps: prepared.gps,
regions: prepared.faces.regions.slice(),
uploaded: '',
count: 0
};
showPreview(state.blob);
gps.style.display = state.gps ? 'block' : 'none';
status.textContent = state.gps
? 'GPS候補があります。位置を使う場合だけチェックしてください。'
: 'GPS候補はありません。位置は後から編集できます。';
if (!prepared.faces.supported || prepared.faces.failed) {
status.textContent += ' 顔の自動検出は利用できません。必ず目視で確認してください。';
} else if (state.regions.length) {
status.textContent += ' 顔候補を検出しました。「選択した箇所をぼかす」を押してください。';
}
} finally {
busy = false;
controls();
}
}
function imagePoint(e) {
const rect = preview.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
};
}
preview.addEventListener('pointerdown', function (e) {
if (!state || state.uploaded || busy || (e.pointerType === 'mouse' && e.button !== 0)) return;
e.preventDefault();
const start = imagePoint(e);
drag = { id: e.pointerId, start: start, region: null };
preview.setPointerCapture(e.pointerId);
status.textContent = 'ぼかしたい範囲をドラッグして、指を離してください。';
});
preview.addEventListener('pointermove', function (e) {
if (!drag || drag.id !== e.pointerId) return;
const end = imagePoint(e);
drag.region = {
x: Math.min(drag.start.x, end.x),
y: Math.min(drag.start.y, end.y),
width: Math.abs(drag.start.x - end.x),
height: Math.abs(drag.start.y - end.y)
};
renderMarks();
});
preview.addEventListener('pointerup', function (e) {
if (!drag || drag.id !== e.pointerId) return;
const end = imagePoint(e);
const region = {
x: Math.min(drag.start.x, end.x),
y: Math.min(drag.start.y, end.y),
width: Math.abs(drag.start.x - end.x),
height: Math.abs(drag.start.y - end.y)
};
drag = null;
const rect = preview.getBoundingClientRect();
if (region.width * rect.width < 5 || region.height * rect.height < 5) {
status.textContent = '範囲が小さすぎます。写真の上をドラッグしてください。';
renderMarks();
return;
}
state.regions.push(region);
confirmed.checked = false;
status.textContent = '赤枠を確認し、「選択した箇所をぼかす」を押してください。';
renderMarks();
controls();
});
preview.addEventListener('pointercancel', function () { drag = null; renderMarks(); });
preview.addEventListener('dragstart', function (e) { e.preventDefault(); });
async function applyMosaic() {
if (!state || !state.regions.length) return;
busy = true;
controls();
try {
const result = await safety.mosaic(
state.base.blob, state.base.width, state.base.height,
state.base.quality, state.regions
);
if (await safety.hasExif(result.blob)) fail('EXIF除去の検証に失敗しました');
state.blob = result.blob;
showPreview(state.blob);
confirmed.checked = false;
status.textContent = 'ぼかし後の画像を表示しました。もう一度確認してください。';
} finally { busy = false; controls(); }
}
async function verifyFestival() {
const id = Number(festivalInput.value);
const year = Number(yearInput.value);
if (!Number.isSafeInteger(id) || id < 1) fail('祭りページIDを指定してください');
if (!Number.isInteger(year) || year < 2000 || year > 2100) fail('開催年を確認してください');
const data = await api.get({
action: 'cargoquery', tables: 'Festivals',
fields: 'festival_id,name', where: 'festival_id=' + id, limit: 1
});
if (!data.cargoquery || !data.cargoquery[0] ||
Number(data.cargoquery[0].title['festival id'] ||
data.cargoquery[0].title.festival_id) !== id) {
fail('登録済みの祭りを選択してください');
}
return { id: id, year: year, name: data.cargoquery[0].title.name };
}
function title() { return '出店写真整理/' + crypto.randomUUID(); }
async function createDraft(fileName, festival, coordinates) {
const page = title();
const text = '{{StallPhotoDraft\n|festival_id=' + festival.id +
'\n|year=' + festival.year + '\n|file=' + fileName +
'\n|latitude=' + (coordinates ? coordinates.latitude.toFixed(8) : '') +
'\n|longitude=' + (coordinates ? coordinates.longitude.toFixed(8) : '') +
'\n|status=pending\n}}';
await api.postWithToken('csrf', {
action: 'edit', title: page, text: text, createonly: 1,
summary: '屋台写真を整理待ちに追加'
});
const li = document.createElement('li');
const link = document.createElement('a');
link.href = mw.util.getUrl(page);
link.textContent = festival.name + ':写真の整理待ち ' + (state.count + 1);
li.append(link);
links.append(li);
state.count++;
return page;
}
async function upload() {
if (!state || busy) return;
if (state.uploaded && state.needsDraft) {
busy = true;
controls();
try {
await createDraft(state.uploaded, state.festival, state.coordinates);
sessionStorage.removeItem('matsuriBatchUnlinkedFile');
state.needsDraft = false;
status.textContent = '整理待ちに保存しました。';
} finally { busy = false; controls(); }
return;
}
if (state.uploaded) return;
if (!confirmed.checked) fail('写真の公開・写り込み確認が必要です');
if (state.regions.length && state.blob === state.base.blob) {
fail('指定されたぼかしを適用してください');
}
busy = true;
controls();
try {
const festival = await verifyFestival();
if (await safety.hasExif(state.blob)) fail('EXIF除去を確認できません');
const filename = 'Matsuri_stall_batch_' + crypto.randomUUID() + '.jpg';
const existing = await api.get({
action: 'query', titles: 'File:' + filename, formatversion: 2
});
if (!existing.query.pages[0].missing) fail('ファイル名が重複しました');
status.textContent = '安全処理済み写真をアップロード中…';
const result = await api.upload(state.blob, {
filename: filename,
comment: '屋台写真の整理待ち。撮影情報を除去し、公開前に確認済み'
});
if (!result.upload || result.upload.result !== 'Success') {
fail('アップロードに成功していません');
}
state.uploaded = result.upload.filename || filename;
state.festival = festival;
state.coordinates = useGps.checked ? state.gps : null;
state.needsDraft = true;
sessionStorage.setItem('matsuriBatchUnlinkedFile', state.uploaded);
await createDraft(state.uploaded, festival, state.coordinates);
sessionStorage.removeItem('matsuriBatchUnlinkedFile');
state.needsDraft = false;
status.textContent = '整理待ちに保存しました。別の屋台が写っている場合は同じ写真から複製できます。';
} finally { busy = false; controls(); }
}
async function duplicate() {
if (!state || !state.uploaded || busy) return;
busy = true;
controls();
try {
await createDraft(state.uploaded, state.festival, state.coordinates);
status.textContent = '同じ写真を参照する整理待ち記録を追加しました。';
} finally { busy = false; controls(); }
}
function next() {
if (busy) return;
index++;
prepare().catch(function (e) { status.textContent = e.message; busy = false; controls(); });
}
const dangling = sessionStorage.getItem('matsuriBatchUnlinkedFile');
if (dangling) status.textContent = '前回、画像の公開後に整理待ちへの保存が中断した可能性があります。ファイル名:' + dangling + '。重複アップロードせず管理者に確認してください。';
}
}());