注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。
- Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
- Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
- Microsoft Edge: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください。
(function () {
'use strict';
if (mw.config.get('wgPageName').startsWith('出店写真整理/')) {
const actions = document.getElementById('stall-photo-draft-actions');
if (!actions) return;
const api = new mw.Api();
['done', 'discarded'].forEach(function (status) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = status === 'done' ? '出店情報への登録完了' : 'この整理待ちを取り消す';
button.addEventListener('click', async function () {
if (!confirm('整理待ちの状態を変更します。公開済みの画像は削除されません。続けますか?')) return;
button.disabled = true;
try {
const page = await api.get({
action: 'query', prop: 'revisions', rvprop: 'timestamp|content',
rvslots: 'main', titles: mw.config.get('wgPageName'), formatversion: 2
});
const rev = page.query.pages[0].revisions[0];
const content = rev.slots.main.content;
if (!/\|status=pending\n/.test(content)) throw new Error('整理待ちではないか、内容が変更されています');
await api.postWithToken('csrf', {
action: 'edit', title: mw.config.get('wgPageName'),
basetimestamp: rev.timestamp,
text: content.replace('|status=pending\n', '|status=' + status + '\n'),
summary: '屋台写真の整理状態を更新'
});
location.reload();
} catch (e) {
button.disabled = false;
alert('更新できませんでした:' + (e.message || String(e)));
}
});
actions.append(button, document.createTextNode(' '));
});
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;
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 + '。重複アップロードせず管理者に確認してください。';
}());