R42-33K 整理待ちから同じ公開写真の別候補を作成
R42-33M 写真保存状態を明示し整理待ちを祭り別に表示
1行目: 1行目:
(function () {
(function () {
     'use strict';
     'use strict';
    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;
    }
     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');
177行目: 305行目:
         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;

2026年9月30日 (水) 17:39時点における版

(function () {
    'use strict';
    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;
    }
    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(' '));
        });
        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 + '。重複アップロードせず管理者に確認してください。';
}());