R9: integrate privacy-safe stall photo uploader
タグ: 差し戻し済み
R9 rollback: restore known-good Common.js after ResourceLoader parse error
タグ: 手動差し戻し
12,810行目: 12,810行目:


} );
} );
/*
* R9-3 MatsuriWiki privacy-safe photo uploader candidate.
*
* Candidate mode:
*  REAL_UPLOAD_ENABLED = false
*
* No production upload can occur while this flag is false.
*/
(function () {
    'use strict';
    const REAL_UPLOAD_ENABLED = true;
    const UI_ID =
        'r9-photo-privacy-uploader';
    const MAX_OUTPUT_BYTES =
        1536 * 1024;
    const MAX_EDGE =
        1600;
    const MIN_EDGE =
        720;
    const MAIN_SELECTOR =
        '[name="FestivalStallPlacement[main_image]"]';
    const POSITION_SELECTOR =
        '[name="FestivalStallPlacement[position_status]"]';
    const LAT_SELECTOR =
        '[name="FestivalStallPlacement[latitude]"]';
    const LON_SELECTOR =
        '[name="FestivalStallPlacement[longitude]"]';
    function text(codePoints) {
        return codePoints
            .map(function (n) {
                return String.fromCodePoint(n);
            })
            .join('');
    }
    const LABELS = {
        title:
            '\u5199\u771f\u3092\u5b89\u5168\u306b\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9',
        help:
            '\u5199\u771f\u306f\u30d6\u30e9\u30a6\u30b6\u5185\u3067\u753b\u50cf\u5316\u3057\u76f4\u3057\u3001GPS\u306a\u3069\u306eEXIF\u30e1\u30bf\u30c7\u30fc\u30bf\u3092\u9664\u53bb\u3057\u3066\u304b\u3089JPEG\u3068\u3057\u3066\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002',
        select:
            '\u5199\u771f\u3092\u9078\u629e',
        publicName:
            '\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d',
        gpsFound:
            'GPS\u4f4d\u7f6e\u5019\u88dc\u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f\u3002\u81ea\u52d5\u3067\u5ea7\u6a19\u306f\u5909\u66f4\u3057\u307e\u305b\u3093\u3002',
        gpsNotFound:
            'GPS\u4f4d\u7f6e\u5019\u88dc\u306f\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002',
        adoptGps:
            '\u3053\u306e\u4f4d\u7f6e\u5019\u88dc\u3092\u4f7f\u7528',
        upload:
            '\u5b89\u5168\u306b\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9',
        reset:
            '\u9078\u629e\u3057\u305f\u5199\u771f\u3092\u30ea\u30bb\u30c3\u30c8',
        processing:
            '\u30d6\u30e9\u30a6\u30b6\u5185\u3067\u5b89\u5168\u51e6\u7406\u4e2d...',
        ready:
            '\u30b5\u30cb\u30bf\u30a4\u30ba\u6e08\u307fJPEG\u306e\u6e96\u5099\u304c\u3067\u304d\u307e\u3057\u305f\u3002',
        dryRun:
            'R9-3 candidate\u306f\u30c9\u30e9\u30a4\u30e9\u30f3\u4e2d\u306e\u305f\u3081\u3001\u5b9f\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306f\u7121\u52b9\u3067\u3059\u3002',
        uploadSuccess:
            '\u5b89\u5168\u306aJPEG\u306e\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u3002',
        uploadFailure:
            '\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002',
        existingFile:
            '\u540c\u3058\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d\u304c\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059\u3002\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u5909\u66f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002',
        badType:
            'JPG\u3001PNG\u3001WebP\u306e\u3044\u305a\u308c\u304b\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002',
        badName:
            '\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002',
        gpsApplied:
            '\u4f4d\u7f6e\u5019\u88dc\u3092\u7def\u5ea6\u30fb\u7d4c\u5ea6\u306b\u53cd\u6620\u3057\u307e\u3057\u305f\u3002\u4f4d\u7f6e\u78ba\u8a8d\u72b6\u614b\u306f\u5909\u66f4\u3057\u3066\u3044\u307e\u305b\u3093\u3002',
        replaceCoords:
            '\u3059\u3067\u306b\u5165\u529b\u3055\u308c\u3066\u3044\u308b\u7def\u5ea6\u30fb\u7d4c\u5ea6\u3092\u3001\u5199\u771f\u306e\u4f4d\u7f6e\u5019\u88dc\u3067\u7f6e\u304d\u63db\u3048\u307e\u3059\u304b\uff1f'
    };
    function dispatchInputChange(element) {
        element.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );
        element.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }
    function ascii(view, offset, count) {
        let out = '';
        if (
            offset < 0 ||
            count < 0 ||
            offset + count > view.byteLength
        ) {
            return '';
        }
        for (
            let i = 0;
            i < count;
            i++
        ) {
            const n =
                view.getUint8(
                    offset + i
                );
            if (n === 0) {
                break;
            }
            out +=
                String.fromCharCode(n);
        }
        return out;
    }
    function detectImageType(buffer) {
        const view =
            new DataView(buffer);
        if (
            view.byteLength >= 3 &&
            view.getUint8(0) === 0xff &&
            view.getUint8(1) === 0xd8 &&
            view.getUint8(2) === 0xff
        ) {
            return 'image/jpeg';
        }
        if (
            view.byteLength >= 8 &&
            view.getUint32(0, false) ===
                0x89504e47 &&
            view.getUint32(4, false) ===
                0x0d0a1a0a
        ) {
            return 'image/png';
        }
        if (
            view.byteLength >= 12 &&
            ascii(
                view,
                0,
                4
            ) === 'RIFF' &&
            ascii(
                view,
                8,
                4
            ) === 'WEBP'
        ) {
            return 'image/webp';
        }
        return '';
    }
    function jpegExif(buffer) {
        const view =
            new DataView(buffer);
        if (
            view.byteLength < 4 ||
            view.getUint16(
                0,
                false
            ) !== 0xffd8
        ) {
            return null;
        }
        let p = 2;
        while (
            p + 4 <=
            view.byteLength
        ) {
            if (
                view.getUint8(p) !==
                0xff
            ) {
                p++;
                continue;
            }
            const marker =
                view.getUint8(
                    p + 1
                );
            if (
                marker === 0xda ||
                marker === 0xd9
            ) {
                return null;
            }
            if (
                marker >= 0xd0 &&
                marker <= 0xd7
            ) {
                p += 2;
                continue;
            }
            if (
                p + 4 >
                view.byteLength
            ) {
                return null;
            }
            const len =
                view.getUint16(
                    p + 2,
                    false
                );
            if (
                len < 2 ||
                p + 2 + len >
                    view.byteLength
            ) {
                return null;
            }
            if (
                marker === 0xe1 &&
                ascii(
                    view,
                    p + 4,
                    6
                ) === 'Exif'
            ) {
                return {
                    view:
                        view,
                    tiff:
                        p + 10
                };
            }
            p +=
                2 + len;
        }
        return null;
    }
    function pngExif(buffer) {
        const view =
            new DataView(buffer);
        if (
            detectImageType(
                buffer
            ) !== 'image/png'
        ) {
            return null;
        }
        let p = 8;
        while (
            p + 12 <=
            view.byteLength
        ) {
            const size =
                view.getUint32(
                    p,
                    false
                );
            const type =
                ascii(
                    view,
                    p + 4,
                    4
                );
            const data =
                p + 8;
            const end =
                data + size;
            if (
                end + 4 >
                view.byteLength
            ) {
                return null;
            }
            if (
                type === 'eXIf'
            ) {
                let tiff =
                    data;
                if (
                    size >= 6 &&
                    ascii(
                        view,
                        data,
                        6
                    ) === 'Exif'
                ) {
                    tiff += 6;
                }
                return {
                    view:
                        view,
                    tiff:
                        tiff
                };
            }
            p =
                end + 4;
        }
        return null;
    }
    function webpExif(buffer) {
        const view =
            new DataView(buffer);
        if (
            detectImageType(
                buffer
            ) !== 'image/webp'
        ) {
            return null;
        }
        let p = 12;
        while (
            p + 8 <=
            view.byteLength
        ) {
            const type =
                ascii(
                    view,
                    p,
                    4
                );
            const size =
                view.getUint32(
                    p + 4,
                    true
                );
            const data =
                p + 8;
            const end =
                data + size;
            if (
                end >
                view.byteLength
            ) {
                return null;
            }
            if (
                type === 'EXIF'
            ) {
                let tiff =
                    data;
                if (
                    size >= 6 &&
                    ascii(
                        view,
                        data,
                        6
                    ) === 'Exif'
                ) {
                    tiff += 6;
                }
                return {
                    view:
                        view,
                    tiff:
                        tiff
                };
            }
            p =
                end +
                (
                    size % 2
                );
        }
        return null;
    }
    function getExifContainer(
        buffer,
        imageType
    ) {
        if (
            imageType ===
            'image/jpeg'
        ) {
            return jpegExif(
                buffer
            );
        }
        if (
            imageType ===
            'image/png'
        ) {
            return pngExif(
                buffer
            );
        }
        if (
            imageType ===
            'image/webp'
        ) {
            return webpExif(
                buffer
            );
        }
        return null;
    }
    function parseGps(
        buffer,
        imageType
    ) {
        const found =
            getExifContainer(
                buffer,
                imageType
            );
        if (!found) {
            return null;
        }
        try {
            const view =
                found.view;
            const tiff =
                found.tiff;
            if (
                tiff < 0 ||
                tiff + 8 >
                    view.byteLength
            ) {
                return null;
            }
            const byteOrder =
                view.getUint16(
                    tiff,
                    false
                );
            const littleEndian =
                byteOrder === 0x4949
                    ? true
                    : byteOrder === 0x4d4d
                        ? false
                        : null;
            if (
                littleEndian ===
                null
            ) {
                return null;
            }
            const u16 =
                function (offset) {
                    if (
                        offset < 0 ||
                        offset + 2 >
                            view.byteLength
                    ) {
                        throw new Error(
                            'EXIF_BOUNDS'
                        );
                    }
                    return view.getUint16(
                        offset,
                        littleEndian
                    );
                };
            const u32 =
                function (offset) {
                    if (
                        offset < 0 ||
                        offset + 4 >
                            view.byteLength
                    ) {
                        throw new Error(
                            'EXIF_BOUNDS'
                        );
                    }
                    return view.getUint32(
                        offset,
                        littleEndian
                    );
                };
            if (
                u16(
                    tiff + 2
                ) !== 42
            ) {
                return null;
            }
            const ifd0 =
                tiff +
                u32(
                    tiff + 4
                );
            const ifd0Count =
                u16(ifd0);
            let gpsOffset =
                null;
            for (
                let i = 0;
                i < ifd0Count;
                i++
            ) {
                const entry =
                    ifd0 +
                    2 +
                    i * 12;
                if (
                    entry + 12 >
                    view.byteLength
                ) {
                    return null;
                }
                if (
                    u16(entry) ===
                    0x8825
                ) {
                    gpsOffset =
                        u32(
                            entry + 8
                        );
                    break;
                }
            }
            if (
                gpsOffset ===
                null
            ) {
                return null;
            }
            const gpsIfd =
                tiff +
                gpsOffset;
            const gpsCount =
                u16(gpsIfd);
            let latRef = '';
            let lonRef = '';
            let latParts = null;
            let lonParts = null;
            const rationalTriplet =
                function (offset) {
                    const values = [];
                    for (
                        let i = 0;
                        i < 3;
                        i++
                    ) {
                        const numerator =
                            u32(
                                offset +
                                i * 8
                            );
                        const denominator =
                            u32(
                                offset +
                                i * 8 +
                                4
                            );
                        if (
                            denominator ===
                            0
                        ) {
                            throw new Error(
                                'EXIF_ZERO_DENOMINATOR'
                            );
                        }
                        values.push(
                            numerator /
                            denominator
                        );
                    }
                    return values;
                };
            for (
                let i = 0;
                i < gpsCount;
                i++
            ) {
                const entry =
                    gpsIfd +
                    2 +
                    i * 12;
                if (
                    entry + 12 >
                    view.byteLength
                ) {
                    return null;
                }
                const tag =
                    u16(entry);
                const type =
                    u16(
                        entry + 2
                    );
                const count =
                    u32(
                        entry + 4
                    );
                const valueField =
                    entry + 8;
                if (
                    (
                        tag === 1 ||
                        tag === 3
                    ) &&
                    type === 2
                ) {
                    const valueOffset =
                        count <= 4
                            ? valueField
                            : tiff +
                                u32(
                                    valueField
                                );
                    const ref =
                        ascii(
                            view,
                            valueOffset,
                            count
                        )
                            .trim()
                            .toUpperCase();
                    if (
                        tag === 1
                    ) {
                        latRef =
                            ref;
                    } else {
                        lonRef =
                            ref;
                    }
                }
                if (
                    (
                        tag === 2 ||
                        tag === 4
                    ) &&
                    type === 5 &&
                    count >= 3
                ) {
                    const dataOffset =
                        tiff +
                        u32(
                            valueField
                        );
                    const parts =
                        rationalTriplet(
                            dataOffset
                        );
                    if (
                        tag === 2
                    ) {
                        latParts =
                            parts;
                    } else {
                        lonParts =
                            parts;
                    }
                }
            }
            if (
                !latRef ||
                !lonRef ||
                !latParts ||
                !lonParts
            ) {
                return null;
            }
            const decimal =
                function (parts) {
                    return (
                        parts[0] +
                        parts[1] / 60 +
                        parts[2] / 3600
                    );
                };
            let latitude =
                decimal(
                    latParts
                );
            let longitude =
                decimal(
                    lonParts
                );
            if (
                latRef ===
                'S'
            ) {
                latitude *= -1;
            }
            if (
                lonRef ===
                'W'
            ) {
                longitude *= -1;
            }
            if (
                !Number.isFinite(
                    latitude
                ) ||
                !Number.isFinite(
                    longitude
                ) ||
                latitude < -90 ||
                latitude > 90 ||
                longitude < -180 ||
                longitude > 180
            ) {
                return null;
            }
            return {
                latitude:
                    latitude,
                longitude:
                    longitude
            };
        } catch (_) {
            return null;
        }
    }
    function canvasToJpeg(
        canvas,
        quality
    ) {
        return new Promise(
            function (
                resolve,
                reject
            ) {
                canvas.toBlob(
                    function (blob) {
                        if (!blob) {
                            reject(
                                new Error(
                                    'CANVAS_TO_BLOB_FAILED'
                                )
                            );
                            return;
                        }
                        resolve(blob);
                    },
                    'image/jpeg',
                    quality
                );
            }
        );
    }
    async function sanitizeImage(
        file
    ) {
        let bitmap = null;
        try {
            try {
                bitmap =
                    await createImageBitmap(
                        file,
                        {
                            imageOrientation:
                                'from-image'
                        }
                    );
            } catch (_) {
                bitmap =
                    await createImageBitmap(
                        file
                    );
            }
            let width =
                bitmap.width;
            let height =
                bitmap.height;
            const initialScale =
                Math.min(
                    1,
                    MAX_EDGE /
                        Math.max(
                            width,
                            height
                        )
                );
            width =
                Math.max(
                    1,
                    Math.round(
                        width *
                        initialScale
                    )
                );
            height =
                Math.max(
                    1,
                    Math.round(
                        height *
                        initialScale
                    )
                );
            const qualities = [
                0.90,
                0.82,
                0.74,
                0.66,
                0.58
            ];
            for (
                let resizePass = 0;
                resizePass < 6;
                resizePass++
            ) {
                const canvas =
                    document.createElement(
                        'canvas'
                    );
                canvas.width =
                    width;
                canvas.height =
                    height;
                const context =
                    canvas.getContext(
                        '2d',
                        {
                            alpha: false
                        }
                    );
                if (!context) {
                    throw new Error(
                        'CANVAS_CONTEXT_FAILED'
                    );
                }
                context.fillStyle =
                    '#fff';
                context.fillRect(
                    0,
                    0,
                    width,
                    height
                );
                context.drawImage(
                    bitmap,
                    0,
                    0,
                    width,
                    height
                );
                for (
                    const quality
                    of qualities
                ) {
                    const blob =
                        await canvasToJpeg(
                            canvas,
                            quality
                        );
                    if (
                        blob.size <=
                        MAX_OUTPUT_BYTES
                    ) {
                        const safeBuffer =
                            await blob
                                .arrayBuffer();
                        if (
                            jpegExif(
                                safeBuffer
                            )
                        ) {
                            throw new Error(
                                'EXIF_REMAINED_AFTER_SANITIZE'
                            );
                        }
                        return {
                            blob:
                                blob,
                            width:
                                width,
                            height:
                                height,
                            quality:
                                quality
                        };
                    }
                }
                const nextWidth =
                    Math.round(
                        width * 0.85
                    );
                const nextHeight =
                    Math.round(
                        height * 0.85
                    );
                if (
                    Math.max(
                        nextWidth,
                        nextHeight
                    ) <
                    MIN_EDGE
                ) {
                    break;
                }
                width =
                    Math.max(
                        1,
                        nextWidth
                    );
                height =
                    Math.max(
                        1,
                        nextHeight
                    );
            }
            throw new Error(
                'SAFE_JPEG_SIZE_LIMIT_FAILED'
            );
        } finally {
            if (
                bitmap &&
                typeof bitmap.close ===
                    'function'
            ) {
                bitmap.close();
            }
        }
    }
    function defaultFilename(
        originalName
    ) {
        let base =
            String(
                originalName ||
                ''
            )
                .replace(
                    /\.[^.]*$/,
                    ''
                )
                .replace(
                    /[\\/:*?"<>|#\[\]{}]+/g,
                    '-'
                )
                .replace(
                    /\s+/g,
                    ' '
                )
                .trim();
        if (!base) {
            base =
                'festival-photo';
        }
        return (
            base +
            '.jpg'
        );
    }
    function normalizedFilename(
        value
    ) {
        let name =
            String(
                value ||
                ''
            )
                .trim()
                .replace(
                    /^File:/i,
                    ''
                )
                .replace(
                    /^\u30d5\u30a1\u30a4\u30eb:/,
                    ''
                );
        if (!name) {
            return '';
        }
        name =
            name.replace(
                /[\\/:*?"<>|#\[\]{}]+/g,
                '-'
            );
        name =
            name.replace(
                /\.[^.]*$/,
                ''
            );
        name =
            name.trim();
        if (!name) {
            return '';
        }
        return (
            name +
            '.jpg'
        );
    }
    function makeElement(
        tag,
        properties
    ) {
        const element =
            document.createElement(
                tag
            );
        Object.keys(
            properties || {}
        ).forEach(
            function (key) {
                if (
                    key ===
                    'style'
                ) {
                    element.style.cssText =
                        properties[key];
                    return;
                }
                if (
                    key ===
                    'textContent'
                ) {
                    element.textContent =
                        properties[key];
                    return;
                }
                element[key] =
                    properties[key];
            }
        );
        return element;
    }
    function initUploader() {
        const mainImage =
            document.querySelector(
                MAIN_SELECTOR
            );
        if (!mainImage) {
            return;
        }
        if (
            document.getElementById(
                UI_ID
            )
        ) {
            return;
        }
        const positionStatus =
            document.querySelector(
                POSITION_SELECTOR
            );
        const latitudeInput =
            document.querySelector(
                LAT_SELECTOR
            );
        const longitudeInput =
            document.querySelector(
                LON_SELECTOR
            );
        if (
            !positionStatus ||
            !latitudeInput ||
            !longitudeInput
        ) {
            return;
        }
        const uploadLink =
            document.querySelector(
                '.ext-pageforms-uploadable' +
                '[data-input-id="' +
                CSS.escape(
                    mainImage.id
                ) +
                '"]'
            );
        if (!uploadLink) {
            return;
        }
        /*
        * Privacy fail-closed:
        * once this workflow is detected,
        * hide the standard Page Forms
        * upload route for this field.
        */
        uploadLink.hidden =
            true;
        uploadLink.setAttribute(
            'aria-hidden',
            'true'
        );
        const originalState = {
            mainImage:
                mainImage.value,
            positionStatus:
                positionStatus.value,
            latitude:
                latitudeInput.value,
            longitude:
                longitudeInput.value
        };
        const state = {
            sourceType:
                '',
            gps:
                null,
            gpsFound:
                false,
            gpsAdopted:
                false,
            safeBlob:
                null,
            safeWidth:
                0,
            safeHeight:
                0,
            safeQuality:
                0,
            safeExifPresent:
                null,
            objectUrl:
                '',
            uploadAttempted:
                false,
            uploadPerformed:
                false,
            uploadErrorCode:
                '',
            uploadedFilename:
                ''
        };
        const box =
            makeElement(
                'div',
                {
                    id:
                        UI_ID,
                    style:
                        'margin-top:.75rem;' +
                        'padding:.85rem;' +
                        'border:1px solid #a2a9b1;' +
                        'border-radius:6px;' +
                        'background:#fff;'
                }
            );
        const heading =
            makeElement(
                'strong',
                {
                    textContent:
                        LABELS.title
                }
            );
        const help =
            makeElement(
                'div',
                {
                    textContent:
                        LABELS.help,
                    style:
                        'margin:.4rem 0 .75rem;'
                }
            );
        const fileLabel =
            makeElement(
                'label',
                {
                    textContent:
                        LABELS.select,
                    style:
                        'display:block;' +
                        'font-weight:600;' +
                        'margin-bottom:.25rem;'
                }
            );
        const fileInput =
            makeElement(
                'input',
                {
                    type:
                        'file',
                    accept:
                        'image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp'
                }
            );
        const nameLabel =
            makeElement(
                'label',
                {
                    textContent:
                        LABELS.publicName,
                    style:
                        'display:block;' +
                        'font-weight:600;' +
                        'margin-top:.75rem;' +
                        'margin-bottom:.25rem;'
                }
            );
        const filenameInput =
            makeElement(
                'input',
                {
                    type:
                        'text',
                    style:
                        'box-sizing:border-box;' +
                        'width:100%;' +
                        'max-width:32rem;'
                }
            );
        const status =
            makeElement(
                'div',
                {
                    style:
                        'margin-top:.65rem;'
                }
            );
        const info =
            makeElement(
                'div',
                {
                    style:
                        'margin-top:.35rem;' +
                        'font-size:.95em;'
                }
            );
        const preview =
            makeElement(
                'img',
                {
                    alt:
                        'privacy-safe local preview',
                    hidden:
                        true,
                    style:
                        'display:block;' +
                        'max-width:260px;' +
                        'max-height:360px;' +
                        'object-fit:contain;' +
                        'margin-top:.65rem;'
                }
            );
        const actions =
            makeElement(
                'div',
                {
                    style:
                        'display:flex;' +
                        'flex-wrap:wrap;' +
                        'gap:.5rem;' +
                        'margin-top:.75rem;'
                }
            );
        const adoptButton =
            makeElement(
                'button',
                {
                    type:
                        'button',
                    textContent:
                        LABELS.adoptGps,
                    hidden:
                        true
                }
            );
        const uploadButton =
            makeElement(
                'button',
                {
                    type:
                        'button',
                    textContent:
                        LABELS.upload,
                    disabled:
                        true
                }
            );
        const resetButton =
            makeElement(
                'button',
                {
                    type:
                        'button',
                    textContent:
                        LABELS.reset,
                    disabled:
                        true
                }
            );
        fileLabel.appendChild(
            fileInput
        );
        actions.append(
            adoptButton,
            uploadButton,
            resetButton
        );
        box.append(
            heading,
            help,
            fileLabel,
            nameLabel,
            filenameInput,
            status,
            info,
            preview,
            actions
        );
        const previewWrapper =
            document.getElementById(
                mainImage.id +
                '_imagepreview'
            );
        (
            previewWrapper ||
            uploadLink
        ).insertAdjacentElement(
            'afterend',
            box
        );
        function revokePreview() {
            if (
                state.objectUrl
            ) {
                URL.revokeObjectURL(
                    state.objectUrl
                );
                state.objectUrl =
                    '';
            }
        }
        function resetSelection() {
            revokePreview();
            fileInput.value =
                '';
            filenameInput.value =
                '';
            preview.removeAttribute(
                'src'
            );
            preview.hidden =
                true;
            adoptButton.hidden =
                true;
            uploadButton.disabled =
                true;
            resetButton.disabled =
                true;
            state.sourceType =
                '';
            state.gps =
                null;
            state.gpsFound =
                false;
            state.gpsAdopted =
                false;
            state.safeBlob =
                null;
            state.safeWidth =
                0;
            state.safeHeight =
                0;
            state.safeQuality =
                0;
            state.safeExifPresent =
                null;
            state.uploadAttempted =
                false;
            state.uploadPerformed =
                false;
            state.uploadErrorCode =
                '';
            state.uploadedFilename =
                '';
            status.textContent =
                '';
            info.textContent =
                '';
        }
        function safeStateReport() {
            return {
                realUploadEnabled:
                    REAL_UPLOAD_ENABLED,
                sourceType:
                    state.sourceType,
                gpsFound:
                    state.gpsFound,
                gpsAdopted:
                    state.gpsAdopted,
                gpsValuesPrinted:
                    false,
                sanitizedReady:
                    !!state.safeBlob,
                sanitizedType:
                    state.safeBlob
                        ? state.safeBlob.type
                        : '',
                sanitizedSize:
                    state.safeBlob
                        ? state.safeBlob.size
                        : 0,
                sanitizedWidth:
                    state.safeWidth,
                sanitizedHeight:
                    state.safeHeight,
                sanitizedExifApp1Found:
                    state.safeExifPresent,
                outputWithin1536KiB:
                    !!state.safeBlob &&
                    state.safeBlob.size <=
                        MAX_OUTPUT_BYTES,
                publicFilename:
                    normalizedFilename(
                        filenameInput.value
                    ),
                mainImageChanged:
                    mainImage.value !==
                    originalState.mainImage,
                positionStatusChanged:
                    positionStatus.value !==
                    originalState.positionStatus,
                latitudeHasValue:
                    !!latitudeInput.value
                        .trim(),
                longitudeHasValue:
                    !!longitudeInput.value
                        .trim(),
                uploadAttempted:
                    state.uploadAttempted,
                uploadPerformed:
                    state.uploadPerformed,
                uploadErrorCode:
                    state.uploadErrorCode,
                uploadedFilename:
                    state.uploadedFilename,
                standardUploadHidden:
                    uploadLink.hidden ===
                    true
            };
        }
        window
            .__r9PhotoPrivacyUploaderState =
            safeStateReport;
        window
            .__r9PhotoPrivacyUploaderCleanup =
            function () {
                revokePreview();
                mainImage.value =
                    originalState.mainImage;
                positionStatus.value =
                    originalState.positionStatus;
                latitudeInput.value =
                    originalState.latitude;
                longitudeInput.value =
                    originalState.longitude;
                box.remove();
                uploadLink.hidden =
                    false;
                uploadLink.removeAttribute(
                    'aria-hidden'
                );
                delete window
                    .__r9PhotoPrivacyUploaderState;
                delete window
                    .__r9PhotoPrivacyUploaderCleanup;
            };
        fileInput.addEventListener(
            'change',
            async function () {
                const file =
                    fileInput.files &&
                    fileInput.files[0];
                if (!file) {
                    return;
                }
                revokePreview();
                state.gps =
                    null;
                state.gpsFound =
                    false;
                state.gpsAdopted =
                    false;
                state.safeBlob =
                    null;
                state.safeExifPresent =
                    null;
                state.uploadAttempted =
                    false;
                state.uploadPerformed =
                    false;
                state.uploadErrorCode =
                    '';
                state.uploadedFilename =
                    '';
                uploadButton.disabled =
                    true;
                resetButton.disabled =
                    false;
                adoptButton.hidden =
                    true;
                preview.hidden =
                    true;
                status.textContent =
                    LABELS.processing;
                info.textContent =
                    '';
                try {
                    const originalBuffer =
                        await file
                            .arrayBuffer();
                    const imageType =
                        detectImageType(
                            originalBuffer
                        );
                    if (
                        ![
                            'image/jpeg',
                            'image/png',
                            'image/webp'
                        ].includes(
                            imageType
                        )
                    ) {
                        throw new Error(
                            'UNSUPPORTED_IMAGE_TYPE'
                        );
                    }
                    state.sourceType =
                        imageType;
                    state.gps =
                        parseGps(
                            originalBuffer,
                            imageType
                        );
                    state.gpsFound =
                        !!state.gps;
                    const safe =
                        await sanitizeImage(
                            file
                        );
                    state.safeBlob =
                        safe.blob;
                    state.safeWidth =
                        safe.width;
                    state.safeHeight =
                        safe.height;
                    state.safeQuality =
                        safe.quality;
                    const safeBuffer =
                        await safe.blob
                            .arrayBuffer();
                    state.safeExifPresent =
                        !!jpegExif(
                            safeBuffer
                        );
                    if (
                        state.safeExifPresent
                    ) {
                        throw new Error(
                            'SANITIZED_JPEG_HAS_EXIF'
                        );
                    }
                    filenameInput.value =
                        defaultFilename(
                            file.name
                        );
                    state.objectUrl =
                        URL.createObjectURL(
                            safe.blob
                        );
                    preview.src =
                        state.objectUrl;
                    preview.hidden =
                        false;
                    adoptButton.hidden =
                        !state.gpsFound;
                    uploadButton.disabled =
                        false;
                    status.textContent =
                        state.gpsFound
                            ? LABELS.gpsFound
                            : LABELS.gpsNotFound;
                    info.textContent =
                        LABELS.ready +
                        ' ' +
                        safe.width +
                        ' x ' +
                        safe.height +
                        ' / ' +
                        Math.ceil(
                            safe.blob.size /
                            1024
                        ) +
                        ' KiB';
                } catch (error) {
                    state.safeBlob =
                        null;
                    uploadButton.disabled =
                        true;
                    adoptButton.hidden =
                        true;
                    preview.hidden =
                        true;
                    if (
                        error &&
                        error.message ===
                            'UNSUPPORTED_IMAGE_TYPE'
                    ) {
                        status.textContent =
                            LABELS.badType;
                    } else {
                        status.textContent =
                            'R9 local processing error: ' +
                            (
                                error &&
                                error.message
                                    ? error.message
                                    : 'UNKNOWN'
                            );
                    }
                }
            }
        );
        adoptButton.addEventListener(
            'click',
            function () {
                if (!state.gps) {
                    return;
                }
                const existingCoordinates =
                    !!latitudeInput.value
                        .trim() ||
                    !!longitudeInput.value
                        .trim();
                if (
                    existingCoordinates &&
                    !window.confirm(
                        LABELS.replaceCoords
                    )
                ) {
                    return;
                }
                latitudeInput.value =
                    state.gps
                        .latitude
                        .toFixed(8);
                longitudeInput.value =
                    state.gps
                        .longitude
                        .toFixed(8);
                dispatchInputChange(
                    latitudeInput
                );
                dispatchInputChange(
                    longitudeInput
                );
                state.gpsAdopted =
                    true;
                status.textContent =
                    LABELS.gpsApplied;
            }
        );
        resetButton.addEventListener(
            'click',
            function () {
                resetSelection();
            }
        );
        uploadButton.addEventListener(
            'click',
            async function () {
                if (!state.safeBlob) {
                    return;
                }
                if (
                    !REAL_UPLOAD_ENABLED
                ) {
                    state.uploadAttempted =
                        false;
                    state.uploadPerformed =
                        false;
                    status.textContent =
                        LABELS.dryRun;
                    return;
                }
                const filename =
                    normalizedFilename(
                        filenameInput.value
                    );
                if (!filename) {
                    status.textContent =
                        LABELS.badName;
                    return;
                }
                state.uploadAttempted =
                    true;
                state.uploadPerformed =
                    false;
                state.uploadErrorCode =
                    '';
                state.uploadedFilename =
                    '';
                uploadButton.disabled =
                    true;
                fileInput.disabled =
                    true;
                filenameInput.disabled =
                    true;
                try {
                    const api =
                        new mw.Api();
                    const query =
                        await api.get({
                            action:
                                'query',
                            titles:
                                'File:' +
                                filename,
                            prop:
                                'imageinfo',
                            iiprop:
                                'url|size|sha1',
                            formatversion:
                                2
                        });
                    const page =
                        query &&
                        query.query &&
                        query.query.pages &&
                        query.query.pages[0]
                            ? query.query.pages[0]
                            : {};
                    if (
                        page.missing !==
                        true
                    ) {
                        state.uploadErrorCode =
                            'FILE_ALREADY_EXISTS';
                        status.textContent =
                            LABELS.existingFile;
                        return;
                    }
                    const result =
                        await new Promise(
                            function (
                                resolve,
                                reject
                            ) {
                                api.upload(
                                    state.safeBlob,
                                    {
                                        filename:
                                            filename,
                                        comment:
                                            'Uploaded via MatsuriWiki privacy-safe photo uploader'
                                    }
                                )
                                    .done(
                                        function (
                                            data
                                        ) {
                                            resolve(
                                                data
                                            );
                                        }
                                    )
                                    .fail(
                                        function (
                                            code,
                                            data
                                        ) {
                                            reject({
                                                code:
                                                    code,
                                                data:
                                                    data
                                            });
                                        }
                                    );
                            }
                        );
                    const upload =
                        result &&
                        result.upload
                            ? result.upload
                            : null;
                    if (
                        !upload ||
                        upload.result !==
                            'Success'
                    ) {
                        throw {
                            code:
                                'UPLOAD_NOT_SUCCESS',
                            data:
                                result
                        };
                    }
                    const uploadedFilename =
                        upload.filename ||
                        filename;
                    /*
                    * This is intentionally the
                    * only main_image write path.
                    * It is reached only after
                    * MediaWiki reports upload
                    * success.
                    */
                    mainImage.value =
                        uploadedFilename;
                    dispatchInputChange(
                        mainImage
                    );
                    state.uploadPerformed =
                        true;
                    state.uploadedFilename =
                        uploadedFilename;
                    status.textContent =
                        LABELS.uploadSuccess;
                } catch (error) {
                    state.uploadPerformed =
                        false;
                    state.uploadErrorCode =
                        error &&
                        error.code
                            ? String(
                                error.code
                            )
                            : 'UNKNOWN';
                    status.textContent =
                        LABELS.uploadFailure +
                        ' [' +
                        state.uploadErrorCode +
                        ']';
                } finally {
                    uploadButton.disabled =
                        !state.safeBlob;
                    fileInput.disabled =
                        false;
                    filenameInput.disabled =
                        false;
                }
            }
        );
        console.log({
            R9_3_PRIVACY_UPLOADER_READY:
                true,
            realUploadEnabled:
                REAL_UPLOAD_ENABLED,
            standardUploadHidden:
                uploadLink.hidden,
            mainImagePreserved:
                mainImage.value ===
                originalState.mainImage,
            positionStatusPreserved:
                positionStatus.value ===
                originalState.positionStatus,
            gpsValuesPrinted:
                false
        });
    }
    mw.loader
        .using(
            'mediawiki.api'
        )
        .then(
            function () {
                if (
                    document.readyState ===
                    'loading'
                ) {
                    document.addEventListener(
                        'DOMContentLoaded',
                        initUploader,
                        {
                            once:
                                true
                        }
                    );
                } else {
                    initUploader();
                }
                if (
                    mw.hook
                ) {
                    mw.hook(
                        'wikipage.content'
                    ).add(
                        initUploader
                    );
                }
            }
        );
}());

2026年9月2日 (水) 02:50時点における版

/* ========================================
 * 屋台比較
 * placement_id 正式版
 *
 * 最大4出店
 *
 * localStorage:
 * matsuriWikiComparePlacements
 *
 * 1 placement =
 * 1 festival + 1 year + 1 venue + 1 stall
 * ======================================== */

mw.loader.using( [
    'mediawiki.storage',
    'mediawiki.api',
    'mediawiki.util'
] ).then( function () {

    'use strict';

    const STORAGE_KEY =
        'matsuriWikiComparePlacements';

    const MAX_COMPARE = 4;

    const api =
        new mw.Api();

    /*
     * ページ表示中だけ使うキャッシュ
     */
    const placementInfoCache = {};


    /* =====================================
     * localStorage
     * ===================================== */

    function getComparePlacements() {

        const raw =
            mw.storage.get(
                STORAGE_KEY
            );

        if ( !raw ) {
            return [];
        }

        try {

            const ids =
                JSON.parse( raw );

            if ( !Array.isArray( ids ) ) {
                return [];
            }

            return [ ...new Set(
                ids
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test( id );
                        }
                    )
            ) ].slice(
                0,
                MAX_COMPARE
            );

        } catch ( e ) {

            return [];

        }

    }


    function saveComparePlacements( ids ) {

        mw.storage.set(
            STORAGE_KEY,
            JSON.stringify( ids )
        );

    }


    /* =====================================
     * Cargo共通処理
     * ===================================== */

    function cargoQuery(
        table,
        fields,
        where,
        limit
    ) {

        const params = {

            action: 'cargoquery',

            tables: table,

            fields: fields,

            limit: limit || 100,

            format: 'json'

        };

        if ( where ) {
            params.where = where;
        }

        return api.get(
            params
        ).then(
            function ( data ) {

                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }

                return data.cargoquery.map(
                    function ( item ) {
                        return item.title || item;
                    }
                );

            }
        );

    }


    function makeInClause( ids ) {

        return ids
            .map( String )
            .filter(
                function ( id ) {
                    return /^\d+$/.test( id );
                }
            )
            .join( ',' );

    }


    function uniqueIds( values ) {

        return [
            ...new Set(
                values
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test( id );
                        }
                    )
            )
        ];

    }


    function mapBy( rows, key ) {

        const result = {};

        rows.forEach(
            function ( row ) {

                if (
                    row[ key ] === undefined ||
                    row[ key ] === null
                ) {
                    return;
                }

                result[
                    String(
                        row[ key ]
                    )
                ] = row;

            }
        );

        return result;

    }


    /* =====================================
     * Placement情報取得
     * ===================================== */

    function fetchPlacementInfo( ids ) {

        if ( ids.length === 0 ) {
            return Promise.resolve( {} );
        }

        const result = {};
        const missingIds = [];

        ids.forEach(
            function ( id ) {

                if (
                    placementInfoCache[ id ]
                ) {

                    result[ id ] =
                        placementInfoCache[ id ];

                } else {

                    missingIds.push( id );

                }

            }
        );


        if ( missingIds.length === 0 ) {

            return Promise.resolve(
                result
            );

        }


        return cargoQuery(

            'FestivalStallPlacements',

            'placement_id=placement_id,' +
            'stall_id=stall_id,' +
            'festival_id=festival_id,' +
            'venue_id=venue_id,' +
            'year=year,' +
            'location_note=location_note',

            'placement_id IN (' +
            makeInClause(
                missingIds
            ) +
            ')',

            100

        ).then(
            function ( placements ) {

                const stallIds =
                    uniqueIds(
                        placements.map(
                            function ( row ) {
                                return row.stall_id;
                            }
                        )
                    );

                const festivalIds =
                    uniqueIds(
                        placements.map(
                            function ( row ) {
                                return row.festival_id;
                            }
                        )
                    );

                const venueIds =
                    uniqueIds(
                        placements.map(
                            function ( row ) {
                                return row.venue_id;
                            }
                        )
                    );


                return Promise.all( [

                    stallIds.length
                        ? cargoQuery(

                            'Stalls',

                            'stall_id=stall_id,' +
                            'name=stall_name,' +
                            '_pageName=page_name',

                            'stall_id IN (' +
                            makeInClause(
                                stallIds
                            ) +
                            ')',

                            100

                        )
                        : Promise.resolve( [] ),


                    festivalIds.length
                        ? cargoQuery(

                            'Festivals',

                            'festival_id=festival_id,' +
                            'name=festival_name,' +
                            '_pageName=page_name',

                            'festival_id IN (' +
                            makeInClause(
                                festivalIds
                            ) +
                            ')',

                            100

                        )
                        : Promise.resolve( [] ),


                    venueIds.length
                        ? cargoQuery(

                            'Venues',

                            'venue_id=venue_id,' +
                            'name=venue_name,' +
                            '_pageName=page_name',

                            'venue_id IN (' +
                            makeInClause(
                                venueIds
                            ) +
                            ')',

                            100

                        )
                        : Promise.resolve( [] )

                ] ).then(
                    function ( related ) {

                        return {

                            placements:
                                placements,

                            stalls:
                                related[ 0 ],

                            festivals:
                                related[ 1 ],

                            venues:
                                related[ 2 ]

                        };

                    }
                );

            }
        ).then(
            function ( data ) {

                const stallMap =
                    mapBy(
                        data.stalls,
                        'stall_id'
                    );

                const festivalMap =
                    mapBy(
                        data.festivals,
                        'festival_id'
                    );

                const venueMap =
                    mapBy(
                        data.venues,
                        'venue_id'
                    );


                data.placements.forEach(
                    function ( placement ) {

                        const placementId =
                            String(
                                placement.placement_id
                            );

                        const info = {

                            placement:
                                placement,

                            stall:
                                stallMap[
                                    String(
                                        placement.stall_id
                                    )
                                ] || null,

                            festival:
                                festivalMap[
                                    String(
                                        placement.festival_id
                                    )
                                ] || null,

                            venue:
                                venueMap[
                                    String(
                                        placement.venue_id
                                    )
                                ] || null

                        };


                        placementInfoCache[
                            placementId
                        ] = info;

                        result[
                            placementId
                        ] = info;

                    }
                );


                return result;

            }
        );

    }


    /* =====================================
     * メッセージ
     * ===================================== */

    function showMessage(
        control,
        message,
        isError
    ) {

        const element =
            control.querySelector(
                '.stall-compare-message'
            );

        if ( !element ) {
            return;
        }

        element.textContent =
            message;

        element.classList.toggle(
            'stall-compare-message-error',
            Boolean( isError )
        );

    }


/* =====================================
 * 比較ボタン生成
 *
 * 通常ページ
 *   stall-compare-placeholder-1
 *
 * 地図popup
 *   stall-map-compare-placeholder-1
 *
 * の両方に対応
 * ===================================== */

function createCompareButtons() {

    document
        .querySelectorAll(
            '.stall-compare-placeholder, ' +
            '.stall-map-compare-placeholder'
        )
        .forEach(
            function ( placeholder ) {

                /*
                 * すでにbutton生成済み
                 */
                if (
                    placeholder.dataset.initialized ===
                    '1'
                ) {
                    return;
                }


                let placementId = '';


                /*
                 * 通常ページ
                 */
                const normalPrefix =
                    'stall-compare-placeholder-';


                /*
                 * 地図popup
                 */
                const mapPrefix =
                    'stall-map-compare-placeholder-';


                if (
                    placeholder.id.startsWith(
                        normalPrefix
                    )
                ) {

                    placementId =
                        placeholder.id.substring(
                            normalPrefix.length
                        );

                } else if (
                    placeholder.id.startsWith(
                        mapPrefix
                    )
                ) {

                    placementId =
                        placeholder.id.substring(
                            mapPrefix.length
                        );

                } else {

                    return;

                }


                /*
                 * placement_idチェック
                 */
                if (
                    !/^\d+$/.test(
                        placementId
                    ) ||
                    placementId === '0'
                ) {
                    return;
                }


                /*
                 * 本物のbuttonを
                 * JavaScript側で作る
                 */
                const button =
                    document.createElement(
                        'button'
                    );


                button.type =
                    'button';


                button.className =
                    'stall-compare-button';


                button.dataset.placementId =
                    placementId;


                button.setAttribute(
                    'aria-pressed',
                    'false'
                );


                button.textContent =
                    '比較に追加';


                placeholder.appendChild(
                    button
                );


                /*
                 * 二重生成防止
                 */
                placeholder.dataset.initialized =
                    '1';

            }
        );

}

    /* =====================================
     * ボタン状態更新
     * ===================================== */

    function updateCompareButtons() {

        const ids =
            getComparePlacements();


        document
            .querySelectorAll(
                '.stall-compare-button'
            )
            .forEach(
                function ( button ) {

                    const placementId =
                        String(
                            button.dataset
                                .placementId || ''
                        );


                    const selected =
                        ids.includes(
                            placementId
                        );


                    if ( selected ) {

                        button.textContent =
                            '比較から外す';

                        button.classList.add(
                            'stall-compare-button-selected'
                        );

                        button.setAttribute(
                            'aria-pressed',
                            'true'
                        );

                    } else {

                        button.textContent =
                            '比較に追加';

                        button.classList.remove(
                            'stall-compare-button-selected'
                        );

                        button.setAttribute(
                            'aria-pressed',
                            'false'
                        );

                    }

                }
            );

    }


    /* =====================================
     * 固定比較トレイ生成
     * ===================================== */

    function createCompareTray() {

        if (
            document.getElementById(
                'stall-compare-tray'
            )
        ) {
            return;
        }


        const tray =
            document.createElement(
                'div'
            );

        tray.id =
            'stall-compare-tray';

        tray.className =
            'stall-compare-tray';

        tray.setAttribute(
            'aria-live',
            'polite'
        );


        const header =
            document.createElement(
                'div'
            );

        header.className =
            'stall-compare-tray-header';


        const title =
            document.createElement(
                'strong'
            );

        title.className =
            'stall-compare-tray-title';

        title.textContent =
            '比較候補';


        const count =
            document.createElement(
                'span'
            );

        count.className =
            'stall-compare-tray-count';


        header.appendChild(
            title
        );

        header.appendChild(
            count
        );


        const items =
            document.createElement(
                'div'
            );

        items.className =
            'stall-compare-tray-items';


        const actions =
            document.createElement(
                'div'
            );

        actions.className =
            'stall-compare-tray-actions';


        const clearButton =
            document.createElement(
                'button'
            );

        clearButton.type =
            'button';

        clearButton.className =
            'stall-compare-clear';

        clearButton.textContent =
            'すべて外す';


        const compareLink =
            document.createElement(
                'a'
            );

        compareLink.className =
            'stall-compare-open';

        compareLink.href =
            mw.util.getUrl(
                '屋台比較'
            );

        compareLink.textContent =
            '比較する';


        actions.appendChild(
            clearButton
        );

        actions.appendChild(
            compareLink
        );


        tray.appendChild(
            header
        );

        tray.appendChild(
            items
        );

        tray.appendChild(
            actions
        );


        document.body.appendChild(
            tray
        );

    }


    /* =====================================
     * 比較トレイ更新
     * ===================================== */

    function updateCompareTray() {

        createCompareTray();


        const tray =
            document.getElementById(
                'stall-compare-tray'
            );

        if ( !tray ) {
            return;
        }


        const ids =
            getComparePlacements();


        const count =
            tray.querySelector(
                '.stall-compare-tray-count'
            );

        const items =
            tray.querySelector(
                '.stall-compare-tray-items'
            );

        const compareLink =
            tray.querySelector(
                '.stall-compare-open'
            );


        if ( count ) {

            count.textContent =
                ids.length +
                ' / ' +
                MAX_COMPARE;

        }


        if ( ids.length === 0 ) {

            tray.classList.remove(
                'stall-compare-tray-visible'
            );

            if ( items ) {
                items.innerHTML = '';
            }

            return;

        }


        tray.classList.add(
            'stall-compare-tray-visible'
        );


        if ( compareLink ) {

            if ( ids.length >= 2 ) {

                compareLink.classList.remove(
                    'stall-compare-open-disabled'
                );

                compareLink.setAttribute(
                    'aria-disabled',
                    'false'
                );

            } else {

                compareLink.classList.add(
                    'stall-compare-open-disabled'
                );

                compareLink.setAttribute(
                    'aria-disabled',
                    'true'
                );

            }

        }


        if ( !items ) {
            return;
        }


        items.textContent =
            '屋台情報を読み込み中…';


        fetchPlacementInfo(
            ids
        ).then(
            function ( placementInfo ) {

                const currentIds =
                    getComparePlacements();

                items.innerHTML =
                    '';


                currentIds.forEach(
                    function ( placementId ) {

                        const info =
                            placementInfo[
                                placementId
                            ];


                        if ( !info ) {
                            return;
                        }


                        const item =
                            document.createElement(
                                'div'
                            );

                        item.className =
                            'stall-compare-tray-item';


                        const text =
                            document.createElement(
                                'div'
                            );


                        const name =
                            document.createElement(
                                info.stall &&
                                info.stall.page_name
                                    ? 'a'
                                    : 'span'
                            );


                        if (
                            info.stall &&
                            info.stall.page_name
                        ) {

                            name.href =
                                mw.util.getUrl(
                                    info.stall.page_name
                                );

                        }


                        name.className =
                            'stall-compare-tray-item-name';

                        name.textContent =
                            info.stall
                                ? info.stall.stall_name
                                : '屋台';


                        const context =
                            document.createElement(
                                'div'
                            );

                        context.className =
                            'stall-compare-tray-item-context';


                        const parts = [];


                        if (
                            info.placement &&
                            info.placement.year
                        ) {

                            parts.push(
                                info.placement.year +
                                '年'
                            );

                        }


                        if (
                            info.festival &&
                            info.festival.festival_name
                        ) {

                            parts.push(
                                info.festival
                                    .festival_name
                            );

                        }


                        if (
                            info.venue &&
                            info.venue.venue_name
                        ) {

                            parts.push(
                                info.venue
                                    .venue_name
                            );

                        }


                        context.textContent =
                            parts.join(
                                ' / '
                            );


                        text.appendChild(
                            name
                        );

                        text.appendChild(
                            context
                        );


                        const removeButton =
                            document.createElement(
                                'button'
                            );

                        removeButton.type =
                            'button';

                        removeButton.className =
                            'stall-compare-tray-remove';

                        removeButton.dataset.placementId =
                            placementId;

                        removeButton.setAttribute(
                            'aria-label',
                            (
                                info.stall
                                    ? info.stall.stall_name
                                    : '屋台'
                            ) +
                            'を比較候補から外す'
                        );

                        removeButton.textContent =
                            '×';


                        item.appendChild(
                            text
                        );

                        item.appendChild(
                            removeButton
                        );

                        items.appendChild(
                            item
                        );

                    }
                );

            }
        ).catch(
            function ( error ) {

                console.error(
                    '比較トレイ取得エラー:',
                    error
                );

                items.textContent =
                    '比較候補を読み込めませんでした。';

            }
        );

    }


    /* =====================================
     * 詳細ページ
     * 「比較に追加」
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const button =
                event.target.closest(
                    '.stall-compare-button'
                );

            if ( !button ) {
                return;
            }


            event.preventDefault();


            const placementId =
                String(
                    button.dataset
                        .placementId || ''
                );


            if (
                !/^\d+$/.test(
                    placementId
                )
            ) {
                return;
            }


            const control =
                button.closest(
                    '.stall-compare-control'
                );


            let ids =
                getComparePlacements();


            const index =
                ids.indexOf(
                    placementId
                );


            /*
             * すでに選択中
             */
            if ( index !== -1 ) {

                ids.splice(
                    index,
                    1
                );

                saveComparePlacements(
                    ids
                );

                updateCompareButtons();
                updateMapCompareLinks();
                updateCompareTray();


                if ( control ) {

                    showMessage(
                        control,
                        '比較候補から外しました。',
                        false
                    );

                }

                return;

            }


            /*
             * 最大4件
             */
            if (
                ids.length >=
                MAX_COMPARE
            ) {

                if ( control ) {

                    showMessage(
                        control,
                        '比較できる出店は最大4件です。',
                        true
                    );

                }

                return;

            }


            ids.push(
                placementId
            );


            saveComparePlacements(
                ids
            );


            updateCompareButtons();
            updateMapCompareLinks();
            updateCompareTray();


            if ( control ) {

                showMessage(
                    control,
                    '比較候補に追加しました(' +
                    ids.length +
                    '/4)。',
                    false
                );

            }

        }
    );


    /* =====================================
     * トレイから1件削除
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const button =
                event.target.closest(
                    '.stall-compare-tray-remove'
                );

            if ( !button ) {
                return;
            }


            event.preventDefault();


            const placementId =
                String(
                    button.dataset
                        .placementId || ''
                );


            let ids =
                getComparePlacements();


            ids =
                ids.filter(
                    function ( id ) {
                        return id !== placementId;
                    }
                );


            saveComparePlacements(
                ids
            );


            updateCompareButtons();
            updateMapCompareLinks();
            updateCompareTray();

        }
    );


    /* =====================================
     * 全削除
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const button =
                event.target.closest(
                    '.stall-compare-clear'
                );

            if ( !button ) {
                return;
            }


            event.preventDefault();


            saveComparePlacements(
                []
            );


            updateCompareButtons();
            updateMapCompareLinks();
            updateCompareTray();

        }
    );


    /* =====================================
     * 1件時の比較リンク無効
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const link =
                event.target.closest(
                    '.stall-compare-open-disabled'
                );

            if ( !link ) {
                return;
            }

            event.preventDefault();

        }
    );


    /* =====================================
     * 初期化
     * ===================================== */

function initPlacementCompare() {

    createCompareButtons();

    createCompareTray();

    updateCompareButtons();

    updateMapCompareLinks();

    updateCompareTray();

}


initPlacementCompare();


mw.hook(
    'wikipage.content'
).add(
    function () {

        initPlacementCompare();

    }
);


/* =====================================
 * 地図popup 比較リンク
 * ===================================== */

function getMapComparePlacementId( link ) {

    if ( !link ) {
        return '';
    }

    const href =
        link.getAttribute( 'href' ) || '';

    const match =
        href.match(
            /#compare-placement-(\d+)$/
        );

    if ( !match ) {
        return '';
    }

    return match[ 1 ];
}


function updateMapCompareLinks() {

    const ids =
        getComparePlacements();

    document
        .querySelectorAll(
            'a[href*="#compare-placement-"]'
        )
        .forEach(
            function ( link ) {

                const placementId =
                    getMapComparePlacementId(
                        link
                    );

                if (
                    !placementId ||
                    placementId === '0'
                ) {
                    return;
                }

                const selected =
                    ids.includes(
                        placementId
                    );

                link.classList.add(
                    'stall-map-compare-link'
                );

                link.dataset.placementId =
                    placementId;

                if ( selected ) {

                    link.textContent =
                        '比較から外す';

                    link.classList.add(
                        'stall-map-compare-link-selected'
                    );

                    link.setAttribute(
                        'aria-pressed',
                        'true'
                    );

                } else {

                    link.textContent =
                        '比較に追加';

                    link.classList.remove(
                        'stall-map-compare-link-selected'
                    );

                    link.setAttribute(
                        'aria-pressed',
                        'false'
                    );

                }

            }
        );

}


/* =====================================
 * 地図popupクリック
 * ===================================== */

document.addEventListener(
    'click',
    function ( event ) {

        const link =
            event.target.closest(
                'a[href*="#compare-placement-"]'
            );

        if ( !link ) {
            return;
        }

        const placementId =
            getMapComparePlacementId(
                link
            );

        if (
            !placementId ||
            placementId === '0'
        ) {
            return;
        }

        event.preventDefault();

        let ids =
            getComparePlacements();

        const index =
            ids.indexOf(
                placementId
            );

        if ( index !== -1 ) {

            ids.splice(
                index,
                1
            );

        } else {

            if (
                ids.length >=
                MAX_COMPARE
            ) {

                link.textContent =
                    '最大4件までです';

                return;
            }

            ids.push(
                placementId
            );

        }


        saveComparePlacements(
            ids
        );

        updateCompareButtons();
        updateMapCompareLinks();
        updateCompareTray();

    }
);


/* =====================================
 * Leaflet popup生成監視
 * ===================================== */

const mapCompareLinkObserver =
    new MutationObserver(
        function ( mutations ) {

            let needsUpdate =
                false;

            mutations.forEach(
                function ( mutation ) {

                    mutation.addedNodes.forEach(
                        function ( node ) {

                            if (
                                node.nodeType !== 1
                            ) {
                                return;
                            }

                            if (
                                node.matches &&
                                node.matches(
                                    'a[href*="#compare-placement-"]'
                                )
                            ) {

                                needsUpdate =
                                    true;

                                return;
                            }

                            if (
                                node.querySelector &&
                                node.querySelector(
                                    'a[href*="#compare-placement-"]'
                                )
                            ) {

                                needsUpdate =
                                    true;

                            }

                        }
                    );

                }
            );


            if ( needsUpdate ) {

                updateMapCompareLinks();

            }

        }
    );


mapCompareLinkObserver.observe(
    document.body,
    {
        childList: true,
        subtree: true
    }
);


/*
 * ★ この } ); が
 * placement比較用mw.loaderの終了
 */
} );

/* ========================================
 * 祭りページ
 * 屋台名・商品名検索
 * ======================================== */

mw.loader.using( [
    'mediawiki.api'
] ).then( function () {

    'use strict';


    const cards =
        Array.from(
            document.querySelectorAll(
                '.festival-stall-search-card'
            )
        );


    /*
     * 屋台カードがないページでは終了
     */
    if (
        cards.length === 0
    ) {
        return;
    }


    const api =
        new mw.Api();


    /* =====================================
     * 文字列正規化
     * ===================================== */

    function normalizeSearchText(
        value
    ) {

        if (
            value === undefined ||
            value === null
        ) {
            return '';
        }


        let text =
            String(
                value
            ).trim();


        /*
         * 全角・半角を可能な範囲で統一
         */
        if (
            typeof text.normalize ===
            'function'
        ) {

            text =
                text.normalize(
                    'NFKC'
                );

        }


        /*
         * 大文字小文字を統一
         */
        text =
            text.toLowerCase();


        /*
         * 連続空白を統一
         */
        text =
            text.replace(
                /\s+/g,
                ' '
            );


        return text;

    }


    /* =====================================
     * Cargo Query
     * ===================================== */

    function cargoQuery(
        table,
        fields,
        where
    ) {

        const params = {

            action:
                'cargoquery',

            tables:
                table,

            fields:
                fields,

            limit:
                500,

            format:
                'json'

        };


        if (
            where
        ) {

            params.where =
                where;

        }


        return api.get(
            params
        ).then(
            function ( data ) {

                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }


                return data.cargoquery.map(
                    function ( item ) {

                        return (
                            item.title ||
                            item
                        );

                    }
                );

            }
        );

    }


    /* =====================================
     * placement_id一覧
     * ===================================== */

    const placementIds =
        cards
            .map(
                function ( card ) {

                    return String(
                        card.dataset
                            .placementId ||
                        ''
                    );

                }
            )
            .filter(
                function ( id ) {

                    return (
                        /^\d+$/.test(
                            id
                        ) &&
                        id !== '0'
                    );

                }
            );


    /*
     * 地図markerを持つplacementだけを
     * 一覧用placementIdsとは分離して管理
     */
    const mapPlacementIds =
        cards
            .filter(
                function ( card ) {

                    const latitude =
                        String(
                            card.dataset
                                .latitude ||
                            ''
                        ).trim();

                    const longitude =
                        String(
                            card.dataset
                                .longitude ||
                            ''
                        ).trim();

                    return (
                        latitude !== '' &&
                        longitude !== ''
                    );

                }
            )
            .map(
                function ( card ) {

                    return String(
                        card.dataset
                            .placementId ||
                        ''
                    );

                }
            )
            .filter(
                function ( id ) {

                    return (
                        /^\d+$/.test(
                            id
                        ) &&
                        id !== '0'
                    );

                }
            );


    if (
        placementIds.length === 0
    ) {
        return;
    }

/* =====================================
 * 屋台一覧 ↔ Leaflet地図連動
 *
 * placement_id を使って
 * markerを表示・非表示
 * ===================================== */

const festivalMapMarkerIndex =
    {};


/*
 * 地図markerの登録が完了したか
 */
let festivalMapMarkerIndexReady =
    false;


/*
 * 地図生成前に検索された場合に備えて
 * 最新の表示対象を保持
 */
let pendingVisiblePlacementIds =
    placementIds.slice();


/*
 * 地図初期化待ち
 */
let mapIndexTimer =
    null;

let mapIndexAttempts =
    0;

const MAX_MAP_INDEX_ATTEMPTS =
    40;


/* =====================================
 * marker popupから
 * placement_idを取得
 *
 * popup内には
 * #compare-placement-3
 * のようなリンクが存在する
 * ===================================== */

function getPlacementIdFromMapMarker(
    marker
) {

    if (
        !marker ||
        typeof marker.getPopup !==
            'function'
    ) {
        return '';
    }


    const popup =
        marker.getPopup();


    if (
        !popup ||
        typeof popup.getContent !==
            'function'
    ) {
        return '';
    }


    const content =
        popup.getContent();


    let popupText =
        '';


    /*
     * 通常はHTML文字列
     */
    if (
        typeof content ===
        'string'
    ) {

        popupText =
            content;

    } else if (
        content &&
        typeof content.outerHTML ===
            'string'
    ) {

        popupText =
            content.outerHTML;

    } else if (
        content &&
        typeof content.innerHTML ===
            'string'
    ) {

        popupText =
            content.innerHTML;

    }


    const match =
        popupText.match(
            /compare-placement-(\d+)/
        );


    if (
        !match
    ) {
        return '';
    }


    return match[
        1
    ];

}


/* =====================================
 * Maps拡張のmarkerを
 * placement_idごとに登録
 * ===================================== */

function buildFestivalMapMarkerIndex() {

    if (
        festivalMapMarkerIndexReady
    ) {
        return true;
    }


    /*
     * 座標付きplacementが0件なら
     * marker indexは0件で正常完了
     */
    if (
        mapPlacementIds.length === 0
    ) {

        festivalMapMarkerIndexReady =
            true;

        return true;
    }


    if (
        !Array.isArray(
            window.mapsLeafletList
        )
    ) {
        return false;
    }


    const expectedIds =
        new Set(
            mapPlacementIds.map(
                String
            )
        );


    window.mapsLeafletList.forEach(
        function ( jqueryMap ) {

            if (
                !jqueryMap ||
                !jqueryMap.mapContent ||
                !jqueryMap.mapContent
                    .markerLayer
            ) {
                return;
            }


            const markerLayer =
                jqueryMap
                    .mapContent
                    .markerLayer;


            if (
                typeof markerLayer
                    .getLayers !==
                'function'
            ) {
                return;
            }


            const markers =
                markerLayer.getLayers();


            markers.forEach(
                function ( marker ) {

                    const placementId =
                        getPlacementIdFromMapMarker(
                            marker
                        );


                    /*
                     * この祭りページの
                     * placementだけ登録
                     */
                    if (
                        !placementId ||
                        !expectedIds.has(
                            placementId
                        )
                    ) {
                        return;
                    }


festivalMapMarkerIndex[
    placementId
] = {

    marker:
        marker,

    markerLayer:
        markerLayer,

    /*
     * このmarkerが所属する地図DOM
     */
    mapElement:
        jqueryMap &&
        jqueryMap[
            0
        ]
            ? jqueryMap[
                0
            ]
            : null

};

                }
            );

        }
    );


    /*
     * 全placementのmarkerが
     * 見つかった場合だけ連動開始
     *
     * 中途半端な状態では
     * 地図を変更しない
     */
    festivalMapMarkerIndexReady =
        Array.from(
            expectedIds
        ).every(
            function (
                placementId
            ) {

                return Boolean(
                    festivalMapMarkerIndex[
                        placementId
                    ]
                );

            }
        );


    return (
        festivalMapMarkerIndexReady
    );

}


/* =====================================
 * marker表示状態を変更
 * ===================================== */

function applyMapMarkerFilter(
    visiblePlacementIds
) {

    const visibleIds =
        new Set(
            visiblePlacementIds.map(
                String
            )
        );


    Object.keys(
        festivalMapMarkerIndex
    ).forEach(
        function ( placementId ) {

            const item =
                festivalMapMarkerIndex[
                    placementId
                ];


            if (
                !item ||
                !item.marker ||
                !item.markerLayer
            ) {
                return;
            }


            const marker =
                item.marker;

            const markerLayer =
                item.markerLayer;


            /*
             * markerが現在表示されているか
             */
            const isShown =
                typeof markerLayer
                    .hasLayer ===
                    'function'
                    ? markerLayer.hasLayer(
                        marker
                    )
                    : true;


            /*
             * 表示対象
             */
            if (
                visibleIds.has(
                    placementId
                )
            ) {

                if (
                    !isShown &&
                    typeof markerLayer
                        .addLayer ===
                        'function'
                ) {

                    markerLayer.addLayer(
                        marker
                    );

                }


            /*
             * 非表示対象
             */
            } else {

                if (
                    isShown &&
                    typeof markerLayer
                        .removeLayer ===
                        'function'
                ) {

                    markerLayer.removeLayer(
                        marker
                    );

                }

            }

        }
    );

}


/* =====================================
 * 地図初期化待ち
 * ===================================== */

function scheduleMapMarkerIndex() {

    if (
        festivalMapMarkerIndexReady
    ) {

        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );

        return;
    }


    /*
     * 二重タイマー防止
     */
    if (
        mapIndexTimer !== null
    ) {
        return;
    }


    function tryIndex() {

        mapIndexTimer =
            null;


        if (
            buildFestivalMapMarkerIndex()
        ) {

            /*
             * 地図準備完了後、
             * 最新の絞り込み状態を反映
             */
            applyMapMarkerFilter(
                pendingVisiblePlacementIds
            );

            return;
        }


        mapIndexAttempts +=
            1;


        if (
            mapIndexAttempts >=
            MAX_MAP_INDEX_ATTEMPTS
        ) {

            console.warn(
                '祭り屋台地図:placement_idとmarkerを対応付けできませんでした。'
            );

            return;
        }


        mapIndexTimer =
            window.setTimeout(
                tryIndex,
                100
            );

    }


    tryIndex();

}


/* =====================================
 * 一覧の検索結果を
 * 地図へ反映
 * ===================================== */

function syncMapMarkers(
    visiblePlacementIds
) {
	
	    pendingVisiblePlacementIds =
        visiblePlacementIds
            .map(
                String
            );


    if (
        buildFestivalMapMarkerIndex()
    ) {

        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );

        return;
    }


    /*
     * Maps側がまだ初期化されていれば待つ
     */
    scheduleMapMarkerIndex();

}
	
	/* =====================================
 * placement_idのmarkerを開く
 * ===================================== */

function openPlacementOnMap(
    placementId
) {

    const id =
        String(
            placementId ||
            ''
        );


    if (
        !/^\d+$/.test(
            id
        ) ||
        id === '0'
    ) {
        return false;
    }


    /*
     * marker index未完成なら
     * 一度構築を試す
     */
    if (
        !festivalMapMarkerIndex[
            id
        ]
    ) {

        buildFestivalMapMarkerIndex();

    }


    const item =
        festivalMapMarkerIndex[
            id
        ];


    if (
        !item ||
        !item.marker
    ) {

        console.warn(
            '地図markerが見つかりません:',
            id
        );

        return false;

    }


    const marker =
        item.marker;


    /*
     * 万一markerが非表示なら
     * 地図へ戻す
     */
    if (
        item.markerLayer &&
        typeof item.markerLayer
            .hasLayer ===
            'function' &&
        !item.markerLayer.hasLayer(
            marker
        ) &&
        typeof item.markerLayer
            .addLayer ===
            'function'
    ) {

        item.markerLayer.addLayer(
            marker
        );

    }


    /*
     * 地図までスクロール
     */
    if (
        item.mapElement &&
        typeof item.mapElement
            .scrollIntoView ===
            'function'
    ) {

        item.mapElement.scrollIntoView(
            {
                behavior:
                    'smooth',

                block:
                    'center'
            }
        );

    }


    /*
     * 少し待ってpopupを開く
     */
    window.setTimeout(
        function () {

            if (
                typeof marker.openPopup ===
                'function'
            ) {

                marker.openPopup();

            }

        },
        300
    );


    return true;

}

/* =====================================
 * 各屋台カード
 * 「地図で見る」ボタン生成
 * ===================================== */

function createMapViewButtons() {

    cards.forEach(
        function ( card ) {

            /*
             * 二重生成防止
             */
            if (
                card.querySelector(
                    '.festival-stall-map-view'
                )
            ) {
                return;
            }


            const placementId =
                String(
                    card.dataset
                        .placementId ||
                    ''
                );


            if (
                !/^\d+$/.test(
                    placementId
                ) ||
                placementId === '0'
            ) {
                return;
            }


            /*
             * 座標なしplacementには
             * 地図ボタンを表示しない
             */
            if (
                !mapPlacementIds.includes(
                    placementId
                )
            ) {
                return;
            }


            const wrapper =
                document.createElement(
                    'div'
                );

            wrapper.className =
                'festival-stall-map-view';


            const button =
                document.createElement(
                    'button'
                );

            button.type =
                'button';

            button.className =
                'festival-stall-map-view-button';

            button.dataset.placementId =
                placementId;

            button.textContent =
                '地図で見る';


            button.setAttribute(
                'aria-label',
                'この屋台を地図で見る'
            );


            wrapper.appendChild(
                button
            );


            /*
             * 比較ボタンの近くへ配置
             */
            const compareControl =
                card.querySelector(
                    '.stall-compare-control'
                );


            if (
                compareControl &&
                compareControl.parentNode
            ) {

                compareControl.parentNode
                    .insertBefore(
                        wrapper,
                        compareControl
                            .nextSibling
                    );

            } else {

                /*
                 * 比較ボタンが見つからない場合は
                 * カード末尾
                 */
                card.appendChild(
                    wrapper
                );

            }

        }
    );

}

/* =====================================
 * 「地図で見る」クリック
 * ===================================== */

document.addEventListener(
    'click',
    function ( event ) {

        const button =
            event.target.closest(
                '.festival-stall-map-view-button'
            );


        if (
            !button
        ) {
            return;
        }


        const placementId =
            String(
                button.dataset
                    .placementId ||
                ''
            );


        const opened =
            openPlacementOnMap(
                placementId
            );


        /*
         * Maps初期化前だった場合
         */
        if (
            !opened
        ) {

            scheduleMapMarkerIndex();


            button.disabled =
                true;

            button.textContent =
                '地図を準備中…';


            window.setTimeout(
                function () {

                    button.disabled =
                        false;

                    button.textContent =
                        '地図で見る';


                    openPlacementOnMap(
                        placementId
                    );

                },
                500
            );

        }

    }
);


    /* =====================================
     * 検索UI
     * ===================================== */

    const searchBox =
        document.createElement(
            'div'
        );

    searchBox.className =
        'festival-stall-search';


    const label =
        document.createElement(
            'label'
        );

    label.className =
        'festival-stall-search-label';

    label.textContent =
        '屋台を検索';


    const input =
        document.createElement(
            'input'
        );

    input.type =
        'search';

    input.className =
        'festival-stall-search-input';

    input.placeholder =
        '屋台名・商品名を入力';

    input.setAttribute(
        'autocomplete',
        'off'
    );

    input.setAttribute(
        'aria-label',
        '屋台名または商品名で検索'
    );

/* =====================================
 * フィルターselect
 * ===================================== */

function createFilterSelect(
    labelText,
    className,
    allText
) {

    const wrapper =
        document.createElement(
            'label'
        );

    wrapper.className =
        'festival-stall-filter';


    const title =
        document.createElement(
            'span'
        );

    title.className =
        'festival-stall-filter-label';

    title.textContent =
        labelText;


    const select =
        document.createElement(
            'select'
        );

    select.className =
        'festival-stall-filter-select ' +
        className;


    const allOption =
        document.createElement(
            'option'
        );

    allOption.value =
        '';

    allOption.textContent =
        allText;


    select.appendChild(
        allOption
    );


    wrapper.appendChild(
        title
    );

    wrapper.appendChild(
        select
    );


    return {
        wrapper:
            wrapper,

        select:
            select
    };

}


/*
 * カテゴリ
 */
const categoryFilter =
    createFilterSelect(
        'カテゴリ',
        'festival-stall-category-filter',
        'すべて'
    );


/*
 * 会場
 */
const venueFilter =
    createFilterSelect(
        '会場',
        'festival-stall-venue-filter',
        'すべて'
    );


const categorySelect =
    categoryFilter.select;


const venueSelect =
    venueFilter.select;


/*
 * フィルター行
 */
const filterRow =
    document.createElement(
        'div'
    );

filterRow.className =
    'festival-stall-search-filters';


filterRow.appendChild(
    categoryFilter.wrapper
);

filterRow.appendChild(
    venueFilter.wrapper
);

/*
 * 絞り込みリセット
 * ===================================== */

const resetButton =
    document.createElement(
        'button'
    );

resetButton.type =
    'button';

resetButton.className =
    'festival-stall-search-reset';

resetButton.textContent =
    '絞り込みをリセット';

resetButton.setAttribute(
    'aria-label',
    '屋台の検索条件をすべてリセット'
);

resetButton.disabled =
    true;

    const count =
        document.createElement(
            'div'
        );

    count.className =
        'festival-stall-search-count';

/* =====================================
 * 検索結果0件メッセージ
 * ===================================== */

const noResults =
    document.createElement(
        'div'
    );

noResults.className =
    'festival-stall-search-empty';

noResults.textContent =
    '条件に一致する屋台はありません。検索条件を変更してください。';

noResults.hidden =
    true;

noResults.setAttribute(
    'role',
    'status'
);

    label.appendChild(
        input
    );

searchBox.appendChild(
    label
);

searchBox.appendChild(
    filterRow
);


/*
 * リセット
 */
searchBox.appendChild(
    resetButton
);


searchBox.appendChild(
    count
);

searchBox.appendChild(
    noResults
);


    /*
     * 最初の屋台カードの直前に表示
     */
/*
 * 検索UIの表示位置
 */
const searchAnchor =
    document.getElementById(
        'festival-stall-search-anchor'
    );


if (
    searchAnchor
) {

    searchAnchor.appendChild(
        searchBox
    );

} else {

    /*
     * 古いテンプレート等への
     * フォールバック
     */
    cards[
        0
    ].parentNode.insertBefore(
        searchBox,
        cards[
            0
        ]
    );

}

    /* =====================================
     * カードごとの検索文字列
     *
     * 最初はカード本文だけ
     * ===================================== */

const searchIndex = {};


/*
 * select候補
 */
const categoryOptions =
    new Map();

const venueOptions =
    new Map();


cards.forEach(
    function ( card ) {

        const placementId =
            String(
                card.dataset
                    .placementId ||
                ''
            );


        const category =
            String(
                card.dataset
                    .category ||
                ''
            ).trim();


        const venueName =
            String(
                card.dataset
                    .venueName ||
                ''
            ).trim();


        const normalizedCategory =
            normalizeSearchText(
                category
            );


        const normalizedVenue =
            normalizeSearchText(
                venueName
            );


        /*
         * placementごとの検索情報
         */
        searchIndex[
            placementId
        ] = {

            text:
                normalizeSearchText(
                    card.textContent
                ),

            category:
                normalizedCategory,

            venueName:
                normalizedVenue

        };


        /*
         * カテゴリselect候補
         */
        if (
            normalizedCategory &&
            !categoryOptions.has(
                normalizedCategory
            )
        ) {

            categoryOptions.set(
                normalizedCategory,
                category
            );

        }


        /*
         * 会場select候補
         */
        if (
            normalizedVenue &&
            !venueOptions.has(
                normalizedVenue
            )
        ) {

            venueOptions.set(
                normalizedVenue,
                venueName
            );

        }

    }
);

/* =====================================
 * select option生成
 * ===================================== */

function fillFilterOptions(
    select,
    optionMap
) {

    const options =
        Array.from(
            optionMap.entries()
        );


    /*
     * 表示名で並び替え
     */
    options.sort(
        function ( a, b ) {

            return a[
                1
            ].localeCompare(
                b[
                    1
                ],
                'ja'
            );

        }
    );


    options.forEach(
        function ( optionData ) {

            const value =
                optionData[
                    0
                ];

            const label =
                optionData[
                    1
                ];


            const option =
                document.createElement(
                    'option'
                );

            option.value =
                value;

            option.textContent =
                label;


            select.appendChild(
                option
            );

        }
    );

}


fillFilterOptions(
    categorySelect,
    categoryOptions
);


fillFilterOptions(
    venueSelect,
    venueOptions
);

    /* =====================================
     * 件数表示
     * ===================================== */

    function updateCount(
        visible
    ) {

        count.textContent =
            '表示:' +
            visible +
            ' / ' +
            cards.length +
            '件';

    }


    updateCount(
        cards.length
    );


    /* =====================================
     * 検索実行
     * ===================================== */

function applySearch() {

    /*
     * フリーワード
     */
    const keyword =
        normalizeSearchText(
            input.value
        );


    /*
     * カテゴリ
     */
    const selectedCategory =
        categorySelect.value;


    /*
     * 会場
     */
    const selectedVenue =
        venueSelect.value;


let visible =
    0;


/*
 * 地図に残すplacement_id
 */
const visiblePlacementIds =
    [];


cards.forEach(
        function ( card ) {

            const placementId =
                String(
                    card.dataset
                        .placementId ||
                    ''
                );


            const index =
                searchIndex[
                    placementId
                ] || {

                    text:
                        '',

                    category:
                        '',

                    venueName:
                        ''

                };


            /* =============================
             * フリーワード
             * ============================= */

            const keywordMatched =
                !keyword ||
                index.text.includes(
                    keyword
                );


            /* =============================
             * カテゴリ
             * ============================= */

            const categoryMatched =
                !selectedCategory ||
                index.category ===
                    selectedCategory;


            /* =============================
             * 会場
             * ============================= */

            const venueMatched =
                !selectedVenue ||
                index.venueName ===
                    selectedVenue;


            /* =============================
             * AND条件
             * ============================= */

            const matched =
                keywordMatched &&
                categoryMatched &&
                venueMatched;


if (
    matched
) {

    card.style.display =
        '';

    visible +=
        1;


    /*
     * 地図にも残す
     */
    visiblePlacementIds.push(
        placementId
    );

} else {

                card.style.display =
                    'none';

            }

        }
    );


updateCount(
    visible
);


/*
 * 0件メッセージ
 */
noResults.hidden =
    visible !== 0;


/*
 * 検索条件が1つでもあれば
 * リセットボタンを有効化
 */
resetButton.disabled =
    (
        normalizeSearchText(
            input.value
        ) === '' &&
        categorySelect.value === '' &&
        venueSelect.value === ''
    );


/*
 * 地図を一覧と同期
 */
syncMapMarkers(
    visiblePlacementIds
);


}


/*
 * 各カードへ
 * 地図で見るボタン
 */
createMapViewButtons();

/*
 * 初期状態
 *
 * 最初は全placementを表示
 */
syncMapMarkers(
    placementIds
);


    input.addEventListener(
        'input',
        applySearch
    );

categorySelect.addEventListener(
    'change',
    applySearch
);


venueSelect.addEventListener(
    'change',
    applySearch
);

/* =====================================
 * 絞り込みをすべてリセット
 * ===================================== */

resetButton.addEventListener(
    'click',
    function () {

        /*
         * フリーワード
         */
        input.value =
            '';


        /*
         * カテゴリ
         */
        categorySelect.value =
            '';


        /*
         * 会場
         */
        venueSelect.value =
            '';


        /*
         * 一覧・件数・0件表示・
         * 地図markerをすべて再計算
         */
        applySearch();


        /*
         * 続けて検索しやすくする
         */
        input.focus();

    }
);

    /* =====================================
     * Placement → Offering取得
     * ===================================== */

    cargoQuery(

        'FestivalStallMenuOfferings',

        'placement_id=placement_id,' +
        'menu_item_id=menu_item_id',

        'placement_id IN (' +
        placementIds.join(
            ','
        ) +
        ')'

    ).then(
        function ( offerings ) {


            const menuItemIds =
                [
                    ...new Set(
                        offerings
                            .map(
                                function (
                                    offering
                                ) {

                                    return String(
                                        offering
                                            .menu_item_id ||
                                        ''
                                    );

                                }
                            )
                            .filter(
                                function ( id ) {

                                    return /^\d+$/.test(
                                        id
                                    );

                                }
                            )
                    )
                ];


            /*
             * メニューが1件も無い
             */
            if (
                menuItemIds.length === 0
            ) {

                return {
                    offerings:
                        offerings,

                    menus:
                        []
                };

            }


            /* =================================
             * MenuItem名取得
             * ================================= */

            return cargoQuery(

                'StallMenuItems',

                'menu_item_id=menu_item_id,' +
                'name=menu_name',

                'menu_item_id IN (' +
                menuItemIds.join(
                    ','
                ) +
                ')'

            ).then(
                function ( menus ) {

                    return {

                        offerings:
                            offerings,

                        menus:
                            menus

                    };

                }
            );

        }
    ).then(
        function ( data ) {

            if (
                !data
            ) {
                return;
            }


            /* =================================
             * menu_item_id → 商品名
             * ================================= */

            const menuNameMap =
                {};


            data.menus.forEach(
                function ( menu ) {

                    menuNameMap[
                        String(
                            menu.menu_item_id
                        )
                    ] =
                        menu.menu_name ||
                        '';

                }
            );


            /* =================================
             * placement_id → 商品名[]
             * ================================= */

            const placementMenus =
                {};


            data.offerings.forEach(
                function ( offering ) {

                    const placementId =
                        String(
                            offering
                                .placement_id ||
                            ''
                        );

                    const menuItemId =
                        String(
                            offering
                                .menu_item_id ||
                            ''
                        );


                    const menuName =
                        menuNameMap[
                            menuItemId
                        ] || '';


                    if (
                        !menuName
                    ) {
                        return;
                    }


                    if (
                        !placementMenus[
                            placementId
                        ]
                    ) {

                        placementMenus[
                            placementId
                        ] = [];

                    }


                    placementMenus[
                        placementId
                    ].push(
                        menuName
                    );

                }
            );


            /* =================================
             * 商品名を検索インデックスへ追加
             * ================================= */

            cards.forEach(
                function ( card ) {

                    const placementId =
                        String(
                            card.dataset
                                .placementId ||
                            ''
                        );


                    const menuNames =
                        placementMenus[
                            placementId
                        ] || [];


if (
    searchIndex[
        placementId
    ]
) {

    searchIndex[
        placementId
    ].text =
        normalizeSearchText(
            (
                searchIndex[
                    placementId
                ].text ||
                ''
            ) +
            ' ' +
            menuNames.join(
                ' '
            )
        );

}

                }
            );


            /*
             * 商品データ取得後、
             * 入力済み検索を再判定
             */
            applySearch();

        }
    ).catch(
        function ( error ) {

            /*
             * 商品データ取得に失敗しても
             * 屋台名検索は使えるようにする
             */
            console.error(
                '屋台商品検索データ取得エラー:',
                error
            );

        }
    );

} );

/* ========================================
 * 屋台比較ページ
 * placement_id 正式版
 * ======================================== */

mw.loader.using( [
    'mediawiki.storage',
    'mediawiki.api',
    'mediawiki.util'
] ).then( function () {

    'use strict';


    const compareRoot =
        document.getElementById(
            'stall-compare-page'
        );


    /*
     * 屋台比較ページ以外では終了
     */
    if ( !compareRoot ) {
        return;
    }


    const STORAGE_KEY =
        'matsuriWikiComparePlacements';

    const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;

    const api =
        new mw.Api();


    /* =====================================
     * localStorage
     * ===================================== */

    function getPlacementIds() {

        const raw =
            mw.storage.get(
                STORAGE_KEY
            );


        if ( !raw ) {
            return [];
        }


        try {

            const ids =
                JSON.parse(
                    raw
                );


            if (
                !Array.isArray(
                    ids
                )
            ) {
                return [];
            }


            return [ ...new Set(
                ids
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test(
                                id
                            );
                        }
                    )
            ) ].slice(
                0,
                MAX_COMPARE
            );


        } catch ( e ) {

            return [];

        }

    }


    /* =====================================
     * Cargo
     * ===================================== */

    function cargoQuery(
        table,
        fields,
        where,
        limit
    ) {

        const params = {

            action: 'cargoquery',

            tables: table,

            fields: fields,

            limit: limit || 100,

            format: 'json'

        };


        if ( where ) {
            params.where = where;
        }


        return api.get(
            params
        ).then(
            function ( data ) {

                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }


                return data.cargoquery.map(
                    function ( item ) {

                        return (
                            item.title ||
                            item
                        );

                    }
                );

            }
        );

    }


    function makeInClause( ids ) {

        return ids
            .map( String )
            .filter(
                function ( id ) {
                    return /^\d+$/.test(
                        id
                    );
                }
            )
            .join( ',' );

    }


    function uniqueIds( values ) {

        return [
            ...new Set(
                values
                    .map( String )
                    .filter(
                        function ( id ) {

                            return (
                                id &&
                                /^\d+$/.test(
                                    id
                                )
                            );

                        }
                    )
            )
        ];

    }


    function mapBy(
        rows,
        key
    ) {

        const result = {};


        rows.forEach(
            function ( row ) {

                if (
                    row[ key ] ===
                    undefined
                ) {
                    return;
                }


                result[
                    String(
                        row[ key ]
                    )
                ] = row;

            }
        );


        return result;

    }


    /* =====================================
     * 表示ヘルパー
     * ===================================== */

    function textOrDash(
        value
    ) {

        if (
            value === undefined ||
            value === null ||
            value === ''
        ) {
            return '―';
        }

        return String(
            value
        );

    }


function cleanNumber(
    value
) {

    if (
        value === undefined ||
        value === null ||
        String( value ).trim() === ''
    ) {
        return '';
    }

    const number =
        Number(
            value
        );

    if (
        !Number.isFinite(
            number
        )
    ) {
        return '';
    }

    if (
        Number.isInteger(
            number
        )
    ) {

        return String(
            number
        );

    }

    return String(
        Math.round(
            number * 100
        ) / 100
    );

}


/* =====================================
 * 比較計算用数値
 * ===================================== */

function toFiniteNumber(
    value
) {

    if (
        value === undefined ||
        value === null ||
        String( value ).trim() === ''
    ) {
        return null;
    }

    const number =
        Number(
            value
        );

    if (
        !Number.isFinite(
            number
        )
    ) {
        return null;
    }

    return number;

}

    function formatHours(
        placement
    ) {

        if ( !placement ) {
            return '―';
        }


        const open =
            placement.opening_time || '';

        const close =
            placement.closing_time || '';


        if (
            open &&
            close
        ) {

            return (
                open +
                '~' +
                close
            );

        }


        if ( open ) {

            return (
                open +
                '~'
            );

        }


        if ( close ) {

            return (
                '~' +
                close
            );

        }


        if (
            placement.hours_note
        ) {

            return placement.hours_note;

        }


        return '未確認';

    }


    function formatPositionStatus(
        status
    ) {

        switch ( status ) {

case 'exact':
    return '正確な位置';

case 'approximate':
    return 'おおよその位置';

default:
    return '位置未確認';

        }

    }


    function formatVerification(
        status
    ) {

        switch ( status ) {

            case 'verified':
                return '確認済み';

            case 'partially_verified':
                return '一部確認済み';

            case 'outdated':
                return '情報が古い';

            default:
                return '未確認';

        }

    }


    function formatAvailability(
        status
    ) {

        switch ( status ) {

            case 'available':
                return '販売あり';

            case 'unavailable':
                return '販売なし';

            default:
                return '未確認';

        }

    }


/* =====================================
 * 単位価格
 * 表示用
 * ===================================== */

function getUnitPrice(
    offering
) {

    const price =
        toFiniteNumber(
            offering.price
        );

    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


    if (
        price === null ||
        quantity === null ||
        quantity <= 0
    ) {

        return '―';

    }


    const unitPrice =
        Math.round(
            (
                price /
                quantity
            ) *
            100
        ) /
        100;


    const unit =
        offering.serving_unit ||
        '単位';


    return (
        unitPrice +
        '円/' +
        unit
    );

}


/* =====================================
 * 単位価格
 * 比較計算用
 * ===================================== */

function getUnitPriceValue(
    offering
) {

    const price =
        toFiniteNumber(
            offering.price
        );

    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


    if (
        price === null ||
        quantity === null ||
        quantity <= 0
    ) {

        return null;

    }


    return (
        price /
        quantity
    );

}


    /* =====================================
     * DOM
     * ===================================== */

    function createTextCell(
        tagName,
        text
    ) {

        const cell =
            document.createElement(
                tagName
            );

        cell.textContent =
            text;

        return cell;

    }


    /* =====================================
     * メニュー
     * ===================================== */

    function createMenuList(
        menus
    ) {

        const container =
            document.createElement(
                'div'
            );

        container.className =
            'stall-compare-menu-list';


        if (
            !menus ||
            menus.length === 0
        ) {

            container.textContent =
                'メニュー未登録';

            return container;

        }


        menus.forEach(
            function ( item ) {

                const menu =
                    document.createElement(
                        'div'
                    );

                menu.className =
                    'stall-compare-menu-item';


                const name =
                    document.createElement(
                        'strong'
                    );

                name.className =
                    'stall-compare-menu-name';

                name.textContent =
                    item.menuName ||
                    '商品';


const price =
    document.createElement(
        'div'
    );

price.className =
    'stall-compare-menu-price';


price.textContent =
    item.price
        ? item.price +
          '円'
        : '価格未確認';


/*
 * 最安価格
 */
if (
    item.isLowestPrice
) {

    const badge =
        document.createElement(
            'span'
        );

    badge.className =
        'stall-compare-best-badge ' +
        'stall-compare-best-price';

    badge.textContent =
        '最安価格';


    price.appendChild(
        document.createTextNode(
            ' '
        )
    );

    price.appendChild(
        badge
    );

}


                const serving =
                    document.createElement(
                        'div'
                    );


                if (
                    item.servingQuantity
                ) {

                    serving.textContent =
                        '内容量:' +
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );

                } else {

                    serving.textContent =
                        '内容量:未確認';

                }


const unit =
    document.createElement(
        'div'
    );

unit.className =
    'stall-compare-menu-unit-price';


unit.textContent =
    '1単位あたり:' +
    item.unitPrice;


/*
 * 最安単位価格
 */
if (
    item.isLowestUnitPrice
) {

    const badge =
        document.createElement(
            'span'
        );

    badge.className =
        'stall-compare-best-badge ' +
        'stall-compare-best-unit-price';

    badge.textContent =
        '最安単位価格';


    unit.appendChild(
        document.createTextNode(
            ' '
        )
    );

    unit.appendChild(
        badge
    );

}


                const availability =
                    document.createElement(
                        'div'
                    );

                availability.textContent =
                    '販売状況:' +
                    item.availability;


                menu.appendChild(
                    name
                );

                menu.appendChild(
                    price
                );

                menu.appendChild(
                    serving
                );

                menu.appendChild(
                    unit
                );

                menu.appendChild(
                    availability
                );


                container.appendChild(
                    menu
                );

            }
        );


        return container;

    }


    /* =====================================
     * 比較表
     * ===================================== */

    function renderComparison(
        compareData
    ) {

        compareRoot.innerHTML =
            '';


        const heading =
            document.createElement(
                'h2'
            );

        heading.textContent =
            '屋台比較';


        compareRoot.appendChild(
            heading
        );


        const wrapper =
            document.createElement(
                'div'
            );

        wrapper.className =
            'stall-compare-table-wrapper';


        const table =
            document.createElement(
                'table'
            );

        table.className =
            'stall-compare-table';


        /* ------------------------------
         * thead
         * ------------------------------ */

        const thead =
            document.createElement(
                'thead'
            );

        const headerRow =
            document.createElement(
                'tr'
            );


        headerRow.appendChild(
            createTextCell(
                'th',
                '比較項目'
            )
        );


        compareData.forEach(
            function ( data ) {

                const th =
                    document.createElement(
                        'th'
                    );


                if (
                    data.stall &&
                    data.stall.page_name
                ) {

                    const link =
                        document.createElement(
                            'a'
                        );

                    link.href =
                        mw.util.getUrl(
                            data.stall
                                .page_name
                        );

                    link.textContent =
                        data.stall
                            .stall_name ||
                        '屋台';


                    th.appendChild(
                        link
                    );

                } else {

                    th.textContent =
                        data.stall
                            ? data.stall.stall_name
                            : '屋台';

                }


                headerRow.appendChild(
                    th
                );

            }
        );


        thead.appendChild(
            headerRow
        );

        table.appendChild(
            thead
        );


        const tbody =
            document.createElement(
                'tbody'
            );


        function addRow(
            label,
            getter
        ) {

            const tr =
                document.createElement(
                    'tr'
                );


            const labelCell =
                createTextCell(
                    'th',
                    label
                );

            labelCell.scope =
                'row';


            tr.appendChild(
                labelCell
            );


            compareData.forEach(
                function ( data ) {

                    tr.appendChild(
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );

                }
            );


            tbody.appendChild(
                tr
            );

        }


        /* =================================
         * Placement情報
         * ================================= */

        addRow(
            '開催年',
            function ( data ) {

                return data.placement
                    ? data.placement.year +
                      '年'
                    : '―';

            }
        );


        addRow(
            '祭り',
            function ( data ) {

                return data.festival
                    ? data.festival
                        .festival_name
                    : '―';

            }
        );


        addRow(
            '会場',
            function ( data ) {

                return data.venue
                    ? data.venue
                        .venue_name
                    : '―';

            }
        );


        addRow(
            '地域',
            function ( data ) {

                return data.area
                    ? data.area
                        .area_name
                    : '―';

            }
        );


        addRow(
            'カテゴリ',
            function ( data ) {

                return data.stall
                    ? data.stall
                        .category
                    : '―';

            }
        );


        addRow(
            '出店場所',
            function ( data ) {

                return data.placement
                    ? data.placement
                        .location_note
                    : '―';

            }
        );


        addRow(
            '営業時間',
            function ( data ) {

                return formatHours(
                    data.placement
                );

            }
        );


        addRow(
            '位置情報',
            function ( data ) {

                return data.placement
                    ? formatPositionStatus(
                        data.placement
                            .position_status
                    )
                    : '―';

            }
        );


        addRow(
            '確認状態',
            function ( data ) {

                return data.placement
                    ? formatVerification(
                        data.placement
                            .verification_status
                    )
                    : '―';

            }
        );


        /* =================================
         * メニュー
         * ================================= */

        const menuRow =
            document.createElement(
                'tr'
            );


        const menuLabel =
            createTextCell(
                'th',
                'メニュー'
            );

        menuLabel.scope =
            'row';


        menuRow.appendChild(
            menuLabel
        );


        compareData.forEach(
            function ( data ) {

                const td =
                    document.createElement(
                        'td'
                    );


                td.appendChild(
                    createMenuList(
                        data.menus
                    )
                );


                menuRow.appendChild(
                    td
                );

            }
        );


        tbody.appendChild(
            menuRow
        );


        table.appendChild(
            tbody
        );

        wrapper.appendChild(
            table
        );

        compareRoot.appendChild(
            wrapper
        );


        const note =
            document.createElement(
                'p'
            );

        note.className =
            'stall-compare-note';

        note.textContent =
            'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';


        compareRoot.appendChild(
            note
        );

    }


    function renderMessage(
        message
    ) {

        compareRoot.innerHTML =
            '';


        const p =
            document.createElement(
                'p'
            );

        p.className =
            'stall-compare-page-message';

        p.textContent =
            message;


        compareRoot.appendChild(
            p
        );

    }


    /* =====================================
     * データ取得
     * ===================================== */

    const placementIds =
        getPlacementIds();


    if (
        placementIds.length <
        MIN_COMPARE
    ) {

        renderMessage(
            '比較する出店を2件以上選択してください。'
        );

        return;

    }


    /*
     * STEP 1
     * Placementを直接取得
     */
    cargoQuery(

        'FestivalStallPlacements',

        'placement_id=placement_id,' +
        'stall_id=stall_id,' +
        'festival_id=festival_id,' +
        'venue_id=venue_id,' +
        'year=year,' +
        'latitude=latitude,' +
        'longitude=longitude,' +
        'location_note=location_note,' +
        'opening_time=opening_time,' +
        'closing_time=closing_time,' +
        'hours_note=hours_note,' +
        'position_status=position_status,' +
        'verification_status=verification_status',

        'placement_id IN (' +
        makeInClause(
            placementIds
        ) +
        ')',

        100

    ).then(
        function ( placements ) {

            const stallIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.stall_id;
                        }
                    )
                );


            const festivalIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.festival_id;
                        }
                    )
                );


            const venueIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.venue_id;
                        }
                    )
                );


            /*
             * STEP 2
             */
            return Promise.all( [

                stallIds.length
                    ? cargoQuery(

                        'Stalls',

                        'stall_id=stall_id,' +
                        'name=stall_name,' +
                        'category=category,' +
                        '_pageName=page_name',

                        'stall_id IN (' +
                        makeInClause(
                            stallIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                festivalIds.length
                    ? cargoQuery(

                        'Festivals',

                        'festival_id=festival_id,' +
                        'name=festival_name,' +
                        '_pageName=page_name',

                        'festival_id IN (' +
                        makeInClause(
                            festivalIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                venueIds.length
                    ? cargoQuery(

                        'Venues',

                        'venue_id=venue_id,' +
                        'name=venue_name,' +
                        'area_id=area_id,' +
                        '_pageName=page_name',

                        'venue_id IN (' +
                        makeInClause(
                            venueIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                cargoQuery(

                    'FestivalStallMenuOfferings',

                    'placement_id=placement_id,' +
                    'menu_item_id=menu_item_id,' +
                    'price=price,' +
                    'serving_quantity=serving_quantity,' +
                    'serving_unit=serving_unit,' +
                    'availability=availability,' +
                    'verification_status=verification_status,' +
                    'sort_order=sort_order',

                    'placement_id IN (' +
                    makeInClause(
                        placementIds
                    ) +
                    ')',

                    100

                )

            ] ).then(
                function ( results ) {

                    return {

                        placements:
                            placements,

                        stalls:
                            results[ 0 ],

                        festivals:
                            results[ 1 ],

                        venues:
                            results[ 2 ],

                        offerings:
                            results[ 3 ]

                    };

                }
            );

        }
    ).then(
        function ( data ) {

            const areaIds =
                uniqueIds(
                    data.venues.map(
                        function ( row ) {
                            return row.area_id;
                        }
                    )
                );


            const menuItemIds =
                uniqueIds(
                    data.offerings.map(
                        function ( row ) {
                            return row.menu_item_id;
                        }
                    )
                );


            /*
             * STEP 3
             */
            return Promise.all( [

                areaIds.length
                    ? cargoQuery(

                        'Areas',

                        'area_id=area_id,' +
                        'name=area_name,' +
                        '_pageName=page_name',

                        'area_id IN (' +
                        makeInClause(
                            areaIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                menuItemIds.length
                    ? cargoQuery(

                        'StallMenuItems',

'menu_item_id=menu_item_id,' +
'stall_id=stall_id,' +
'name=menu_name,' +
'item_category=item_category',

                        'menu_item_id IN (' +
                        makeInClause(
                            menuItemIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    )

            ] ).then(
                function ( results ) {

                    data.areas =
                        results[ 0 ];

                    data.menuItems =
                        results[ 1 ];

                    return data;

                }
            );

        }
    ).then(
        function ( data ) {

            const placementMap =
                mapBy(
                    data.placements,
                    'placement_id'
                );


            const stallMap =
                mapBy(
                    data.stalls,
                    'stall_id'
                );


            const festivalMap =
                mapBy(
                    data.festivals,
                    'festival_id'
                );


            const venueMap =
                mapBy(
                    data.venues,
                    'venue_id'
                );


            const areaMap =
                mapBy(
                    data.areas,
                    'area_id'
                );


            const menuMap =
                mapBy(
                    data.menuItems,
                    'menu_item_id'
                );


            /*
             * Offering
             * placement単位
             */
            const offeringsByPlacement =
                {};


            data.offerings
                .slice()
                .sort(
                    function ( a, b ) {

                        return (
                            Number(
                                a.sort_order || 0
                            ) -
                            Number(
                                b.sort_order || 0
                            )
                        );

                    }
                )
                .forEach(
                    function ( offering ) {

                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );


                        if (
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {

                            offeringsByPlacement[
                                placementId
                            ] = [];

                        }


                        offeringsByPlacement[
                            placementId
                        ].push(
                            offering
                        );

                    }
                );


            /*
             * localStorage順を維持
             */
            const compareData =
                placementIds.map(
                    function (
                        placementId
                    ) {

                        const placement =
                            placementMap[
                                placementId
                            ] || null;


                        if ( !placement ) {

                            return {

                                placementId:
                                    placementId,

                                placement:
                                    null,

                                stall:
                                    null,

                                festival:
                                    null,

                                venue:
                                    null,

                                area:
                                    null,

                                menus:
                                    []

                            };

                        }


                        const stall =
                            stallMap[
                                String(
                                    placement.stall_id
                                )
                            ] || null;


                        const festival =
                            festivalMap[
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                        const venue =
                            venueMap[
                                String(
                                    placement.venue_id
                                )
                            ] || null;


                        let area = null;


                        if (
                            venue &&
                            venue.area_id
                        ) {

                            area =
                                areaMap[
                                    String(
                                        venue.area_id
                                    )
                                ] || null;

                        }


                        const offerings =
                            offeringsByPlacement[
                                placementId
                            ] || [];


/*
 * Placementに紐づくメニューを生成
 */
const menus =
    offerings.map(
        function ( offering ) {

            const menu =
                menuMap[
                    String(
                        offering.menu_item_id
                    )
                ] || {};


            return {

                /*
                 * どの出店の商品か
                 */
                placementId:
                    placementId,


                menuName:
                    menu.menu_name ||
                    '商品',


                category:
                    menu.item_category ||
                    '',


                /*
                 * 表示価格
                 */
                price:
                    cleanNumber(
                        offering.price
                    ),


                /*
                 * 比較用価格
                 */
                priceValue:
                    toFiniteNumber(
                        offering.price
                    ),


                servingQuantity:
                    cleanNumber(
                        offering
                            .serving_quantity
                    ),


                servingUnit:
                    offering
                        .serving_unit ||
                    '',



                /*
                 * 表示用単位価格
                 */
                unitPrice:
                    getUnitPrice(
                        offering
                    ),


                /*
                 * 比較用単位価格
                 */
                unitPriceValue:
                    getUnitPriceValue(
                        offering
                    ),


                availability:
                    formatAvailability(
                        offering
                            .availability
                    ),


                verification:
                    formatVerification(
                        offering
                            .verification_status
                    ),


                isLowestPrice:
                    false,


                isLowestUnitPrice:
                    false

            };

        }
    );


return {

    placementId:
        placementId,

    placement:
        placement,

    stall:
        stall,

    festival:
        festival,

    venue:
        venue,

    area:
        area,

    menus:
        menus

};

}
);
                        

/* =====================================
 * 最安価格・最安単位価格
 *
 * 「同じ商品名 + 同じ単位」
 * の商品だけを比較する
 * ===================================== */

function markBestPrices(
    compareData
) {

    const allMenus = [];


    /* =================================
     * 比較文字列を正規化
     *
     * 例:
     * "たこ焼き"
     * " たこ焼き "
     *
     * を同じものとして扱う
     * ================================= */

    function normalizeCompareText(
        value
    ) {

        if (
            value === undefined ||
            value === null
        ) {
            return '';
        }

        let text =
            String(
                value
            ).trim();

        /*
         * 全角・半角などを可能な範囲で統一
         */
        if (
            typeof text.normalize ===
            'function'
        ) {

            text =
                text.normalize(
                    'NFKC'
                );

        }

        /*
         * 連続空白を1つにする
         */
        text =
            text.replace(
                /\s+/g,
                ' '
            );

        /*
         * 英字商品名にも対応
         */
        text =
            text.toLowerCase();

        return text;

    }


    /* =================================
     * 全メニューを集める
     * ================================= */

    compareData.forEach(
        function ( data ) {

            if (
                !data.menus ||
                !Array.isArray(
                    data.menus
                )
            ) {
                return;
            }


            data.menus.forEach(
                function ( menu ) {

                    /*
                     * 毎回初期化
                     */
                    menu.isLowestPrice =
                        false;

                    menu.isLowestUnitPrice =
                        false;


                    /*
                     * 比較用の商品名
                     */
                    menu.compareMenuName =
                        normalizeCompareText(
                            menu.menuName
                        );


                    /*
                     * 比較用単位
                     */
                    menu.compareUnit =
                        normalizeCompareText(
                            menu.servingUnit
                        );


                    allMenus.push(
                        menu
                    );

                }
            );

        }
    );


    /* =================================
     * 商品名+単位ごとのグループ
     *
     * 例:
     *
     * たこ焼き + 個
     * 焼きそば + パック
     * りんご飴 + 本
     * ================================= */

    const groups = {};


    allMenus.forEach(
        function ( menu ) {

            /*
             * 商品名が無ければ比較しない
             */
            if (
                !menu.compareMenuName
            ) {
                return;
            }


            /*
             * 単位が無ければ比較しない
             *
             * 「同じ商品名+同じ単位」
             * が条件だから
             */
            if (
                !menu.compareUnit
            ) {
                return;
            }


            const groupKey =
                menu.compareMenuName +
                '||' +
                menu.compareUnit;


            if (
                !groups[
                    groupKey
                ]
            ) {

                groups[
                    groupKey
                ] = [];

            }


            groups[
                groupKey
            ].push(
                menu
            );

        }
    );


    /* =================================
     * グループごとに判定
     * ================================= */

    Object.keys(
        groups
    ).forEach(
        function ( groupKey ) {

            const menus =
                groups[
                    groupKey
                ];


            /* =============================
             * 2出店以上あるか確認
             *
             * 同じ出店内だけの商品比較は
             * 「最安」としない
             * ============================= */

            const placementIds =
                [
                    ...new Set(
                        menus.map(
                            function ( menu ) {

                                return String(
                                    menu.placementId
                                );

                            }
                        )
                    )
                ];


            if (
                placementIds.length < 2
            ) {

                return;

            }


            /* =============================
             * 最安価格
             *
             * 同商品+同単位の
             * 販売価格を比較
             * ============================= */

            const priceCandidates =
                menus.filter(
                    function ( menu ) {

                        return (
                            menu.priceValue !==
                                null &&
                            Number.isFinite(
                                menu.priceValue
                            )
                        );

                    }
                );


            /*
             * 価格が登録されている
             * 出店が2件以上あるか
             */
            const pricePlacementIds =
                [
                    ...new Set(
                        priceCandidates.map(
                            function ( menu ) {

                                return String(
                                    menu.placementId
                                );

                            }
                        )
                    )
                ];


            if (
                pricePlacementIds.length >= 2
            ) {

                const lowestPrice =
                    Math.min.apply(
                        null,
                        priceCandidates.map(
                            function ( menu ) {

                                return menu
                                    .priceValue;

                            }
                        )
                    );


                priceCandidates.forEach(
                    function ( menu ) {

                        /*
                         * 円なので通常整数だが
                         * 小数にも一応対応
                         */
                        if (
                            Math.abs(
                                menu.priceValue -
                                lowestPrice
                            ) <
                            0.000001
                        ) {

                            menu.isLowestPrice =
                                true;

                        }

                    }
                );

            }


            /* =============================
             * 最安単位価格
             *
             * 同商品+同単位で
             * price / quantity を比較
             * ============================= */

            const unitPriceCandidates =
                menus.filter(
                    function ( menu ) {

                        return (
                            menu.unitPriceValue !==
                                null &&
                            Number.isFinite(
                                menu.unitPriceValue
                            )
                        );

                    }
                );


            const unitPricePlacementIds =
                [
                    ...new Set(
                        unitPriceCandidates.map(
                            function ( menu ) {

                                return String(
                                    menu.placementId
                                );

                            }
                        )
                    )
                ];


            if (
                unitPricePlacementIds.length >= 2
            ) {

                const lowestUnitPrice =
                    Math.min.apply(
                        null,
                        unitPriceCandidates.map(
                            function ( menu ) {

                                return menu
                                    .unitPriceValue;

                            }
                        )
                    );


                unitPriceCandidates.forEach(
                    function ( menu ) {

                        /*
                         * 割り算による
                         * 浮動小数誤差対策
                         */
                        if (
                            Math.abs(
                                menu.unitPriceValue -
                                lowestUnitPrice
                            ) <
                            0.000001
                        ) {

                            menu.isLowestUnitPrice =
                                true;

                        }

                    }
                );

            }

        }
    );

}

/* =====================================
 * 商品別比較サマリー
 *
 * 同じ商品名 + 同じ単位でグループ化
 * ===================================== */

function renderProductGroupSummary(
    compareData
) {

    const comparePage =
        document.getElementById(
            'stall-compare-page'
        );

    if (
        !comparePage
    ) {
        return;
    }


    /* =================================
     * 文字列正規化
     * ================================= */

    function normalizeText(
        value
    ) {

        if (
            value === undefined ||
            value === null
        ) {
            return '';
        }

        let text =
            String(
                value
            ).trim();


        if (
            typeof text.normalize ===
            'function'
        ) {

            text =
                text.normalize(
                    'NFKC'
                );

        }


        text =
            text.replace(
                /\s+/g,
                ' '
            );

        return text;

    }

/* =================================
 * 屋台ページリンクを生成
 * ================================= */

function appendStallLinks(
    container,
    items
) {

    const stalls = [];
    const seen = {};


    items.forEach(
        function ( item ) {

            /*
             * page_nameがある場合は
             * page_nameで重複判定
             *
             * 無い場合は名前で判定
             */
            const key =
                item.stallPage
                    ? 'page:' +
                      item.stallPage
                    : 'name:' +
                      item.stallName;


            if (
                seen[
                    key
                ]
            ) {
                return;
            }


            seen[
                key
            ] = true;


            stalls.push(
                {
                    name:
                        item.stallName,

                    page:
                        item.stallPage
                }
            );

        }
    );


    stalls.forEach(
        function (
            stall,
            index
        ) {

            /*
             * 2件目以降の区切り
             */
            if (
                index > 0
            ) {

                container.appendChild(
                    document.createTextNode(
                        '・'
                    )
                );

            }


            /*
             * ページが存在する場合
             * リンクにする
             */
            if (
                stall.page
            ) {

                const link =
                    document.createElement(
                        'a'
                    );

                link.href =
                    mw.util.getUrl(
                        stall.page
                    );

                link.textContent =
                    stall.name;

                link.className =
                    'stall-product-group-stall-link';


                container.appendChild(
                    link
                );

            } else {

                /*
                 * page_nameが取得できない場合
                 * 普通の文字として表示
                 */
                container.appendChild(
                    document.createTextNode(
                        stall.name
                    )
                );

            }

        }
    );

}

    /* =================================
     * 商品グループ作成
     * ================================= */

    const groups = {};


    compareData.forEach(
        function ( data ) {

            if (
                !data.menus ||
                !Array.isArray(
                    data.menus
                )
            ) {
                return;
            }


            data.menus.forEach(
                function ( menu ) {

                    const menuName =
                        normalizeText(
                            menu.menuName
                        );

                    const unit =
                        normalizeText(
                            menu.servingUnit
                        );


                    /*
                     * 商品名または単位が無いものは
                     * 商品比較サマリーから除外
                     */
                    if (
                        !menuName ||
                        !unit
                    ) {
                        return;
                    }


                    const key =
                        menuName.toLowerCase() +
                        '||' +
                        unit.toLowerCase();


                    if (
                        !groups[
                            key
                        ]
                    ) {

                        groups[
                            key
                        ] = {

                            menuName:
                                menuName,

                            unit:
                                unit,

                            items:
                                []

                        };

                    }


                    groups[
    key
].items.push(
    {

        placementId:
            String(
                menu.placementId
            ),

        stallName:
            (
                data.stall &&
                data.stall.stall_name
            )
                ? data.stall.stall_name
                : '屋台',

        /*
         * 屋台ページ名
         */
        stallPage:
            (
                data.stall &&
                data.stall.page_name
            )
                ? data.stall.page_name
                : '',

        menu:
            menu

    }
);

                }
            );

        }
    );


    const groupKeys =
        Object.keys(
            groups
        );


    if (
        groupKeys.length === 0
    ) {
        return;
    }


    /* =================================
     * サマリー全体
     * ================================= */

    const summary =
        document.createElement(
            'section'
        );

    summary.className =
        'stall-product-group-summary';


    const title =
        document.createElement(
            'h2'
        );

    title.className =
        'stall-product-group-summary-title';

    title.textContent =
        '商品別比較サマリー';


    summary.appendChild(
        title
    );


    /* =================================
     * 各商品グループ
     * ================================= */

    groupKeys.forEach(
        function ( key ) {

            const group =
                groups[
                    key
                ];

            const items =
                group.items;


            /*
             * 同じPlacementを重複カウントしない
             */
            const placementIds =
                [
                    ...new Set(
                        items.map(
                            function ( item ) {

                                return item
                                    .placementId;

                            }
                        )
                    )
                ];


            const card =
                document.createElement(
                    'div'
                );

            card.className =
                'stall-product-group-card';


            /* =============================
             * 商品名
             * ============================= */

            const heading =
                document.createElement(
                    'h3'
                );

            heading.className =
                'stall-product-group-name';

            heading.textContent =
                group.menuName +
                ' / ' +
                group.unit;


            card.appendChild(
                heading
            );


            /* =============================
             * 比較店舗数
             * ============================= */

            const count =
                document.createElement(
                    'div'
                );

            count.className =
                'stall-product-group-count';

            count.textContent =
                '比較店舗:' +
                placementIds.length +
                '店';


            card.appendChild(
                count
            );

/* =============================
 * 対象店舗リンク
 * ============================= */

const stallList =
    document.createElement(
        'div'
    );

stallList.className =
    'stall-product-group-stalls';


const stallListLabel =
    document.createElement(
        'span'
    );

stallListLabel.className =
    'stall-product-group-label';

stallListLabel.textContent =
    '対象店舗:';


stallList.appendChild(
    stallListLabel
);


/*
 * 屋台名をリンクとして追加
 */
appendStallLinks(
    stallList,
    items
);


card.appendChild(
    stallList
);

            /* =============================
             * 1店舗しかない場合
             * ============================= */

            if (
                placementIds.length < 2
            ) {

                const notice =
                    document.createElement(
                        'div'
                    );

                notice.className =
                    'stall-product-group-notice';

                notice.textContent =
                    '比較対象が1店舗のみです。';


                card.appendChild(
                    notice
                );

            }


            /* =============================
             * 最安価格の商品
             * ============================= */

            const lowestPriceItems =
                items.filter(
                    function ( item ) {

                        return (
                            item.menu
                                .isLowestPrice ===
                            true
                        );

                    }
                );


            if (
                lowestPriceItems.length > 0
            ) {

                const lowestPrice =
                    lowestPriceItems[
                        0
                    ].menu.priceValue;


                const row =
                    document.createElement(
                        'div'
                    );

                row.className =
                    'stall-product-group-best';


                const label =
                    document.createElement(
                        'span'
                    );

                label.className =
                    'stall-product-group-label';

                label.textContent =
                    '最安価格:';


                const value =
                    document.createElement(
                        'strong'
                    );

                value.textContent =
                    lowestPrice +
                    '円';


                row.appendChild(
                    label
                );

                row.appendChild(
                    value
                );


                card.appendChild(
                    row
                );


                /*
 * 最安店舗リンク
 */
const shopRow =
    document.createElement(
        'div'
    );

shopRow.className =
    'stall-product-group-shop';


const shopLabel =
    document.createElement(
        'span'
    );

shopLabel.className =
    'stall-product-group-label';

shopLabel.textContent =
    '最安:';


shopRow.appendChild(
    shopLabel
);


/*
 * 最安店舗をリンク表示
 */
appendStallLinks(
    shopRow,
    lowestPriceItems
);


card.appendChild(
    shopRow
);

            }


            /* =============================
             * 最安単位価格
             * ============================= */

            const lowestUnitItems =
                items.filter(
                    function ( item ) {

                        return (
                            item.menu
                                .isLowestUnitPrice ===
                            true
                        );

                    }
                );


            if (
                lowestUnitItems.length > 0
            ) {

                const unitPrice =
                    lowestUnitItems[
                        0
                    ].menu.unitPriceValue;


                /*
                 * 小数表示調整
                 */
                const displayUnitPrice =
                    Math.round(
                        unitPrice *
                        100
                    ) /
                    100;


                const row =
                    document.createElement(
                        'div'
                    );

                row.className =
                    'stall-product-group-best-unit';


                const label =
                    document.createElement(
                        'span'
                    );

                label.className =
                    'stall-product-group-label';

                label.textContent =
                    '最安単位価格:';


                const value =
                    document.createElement(
                        'strong'
                    );

                value.textContent =
                    displayUnitPrice +
                    '円/' +
                    group.unit;


                row.appendChild(
                    label
                );

                row.appendChild(
                    value
                );


                card.appendChild(
                    row
                );

            }


            summary.appendChild(
                card
            );

        }
    );


    /*
     * 比較表の一番上へ追加
     */
    comparePage.insertBefore(
        summary,
        comparePage.firstChild
    );

}

/* =================================
 * 最安値を自動判定
 * ================================= */

markBestPrices(
    compareData
);


renderComparison(
    compareData
);


/*
 * 詳細比較表を描画した後に
 * 商品別サマリーを追加
 */
renderProductGroupSummary(
    compareData
);

        }
    ).catch(
        function ( error ) {

            console.error(
                'Placement比較データ取得エラー:',
                error
            );


            renderMessage(
                '比較データの取得中にエラーが発生しました。'
            );

        }
    );



} );

$(function () {
const statusLabels = {
    active: '出店中・出店予定',
    cancelled: '出店中止',
    unknown: '未確認'
};

    const statusSelect = document.querySelector(
        'select[name="FestivalStallPlacement[status]"]'
    );

    if (statusSelect) {
        Array.from(statusSelect.options).forEach(function (option) {
            if (statusLabels[option.value]) {
                option.textContent = statusLabels[option.value];
            }
        });
    }

    const verificationLabels = {
        verified: '確認済み',
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };

    const verificationSelect = document.querySelector(
        'select[name="FestivalStallPlacement[verification_status]"]'
    );

    if (verificationSelect) {
        Array.from(verificationSelect.options).forEach(function (option) {
            if (verificationLabels[option.value]) {
                option.textContent = verificationLabels[option.value];
            }
        });
    }
    
    const yearInput = document.querySelector(
    'input[name="FestivalStallPlacement[year]"]'
);

if (yearInput) {
    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;

    const validateYear = function () {
        const value = yearInput.value.trim();

        if (value !== '' && !/^\d{4}$/.test(value)) {
            yearInput.setCustomValidity(
                '開催年は4桁の数字で入力してください(例:2026)'
            );
        } else {
            yearInput.setCustomValidity('');
        }
    };

    yearInput.addEventListener('input', validateYear);
    yearInput.addEventListener('change', validateYear);
    yearInput.addEventListener('invalid', validateYear);

    validateYear();
}
    
const positionLabels = {
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};

const positionSelect = document.querySelector(
    'select[name="FestivalStallPlacement[position_status]"]'
);

if (positionSelect) {
    Array.from(positionSelect.options).forEach(function (option) {
        if (positionLabels[option.value]) {
            option.textContent = positionLabels[option.value];
        }
    });
}
    
    const accuracyInput = document.querySelector(
    'input[name="FestivalStallPlacement[position_accuracy_m]"]'
);

if (accuracyInput) {
    accuracyInput.inputMode = 'numeric';

    const validateAccuracy = function () {
        const value = accuracyInput.value.trim();

        if (value !== '' && !/^\d+$/.test(value)) {
            accuracyInput.setCustomValidity(
                '位置精度は0以上の整数で入力してください(例:10)'
            );
        } else {
            accuracyInput.setCustomValidity('');
        }
    };

    accuracyInput.addEventListener('input', validateAccuracy);
    accuracyInput.addEventListener('change', validateAccuracy);
    accuracyInput.addEventListener('invalid', validateAccuracy);

    validateAccuracy();
}
    
    const openingTimeInput = document.querySelector(
    'input[name="FestivalStallPlacement[opening_time]"]'
);

const closingTimeInput = document.querySelector(
    'input[name="FestivalStallPlacement[closing_time]"]'
);

const timePattern = /^([01]\d|2[0-3]):[0-5]\d$/;

function setupTimeValidation(input, label) {
    if (!input) {
        return;
    }

    input.placeholder = '例:10:00';

    const validateTime = function () {
        const value = input.value.trim();

        input.setCustomValidity('');

        if (value !== '' && !timePattern.test(value)) {
            input.setCustomValidity(
                label + 'は24時間表記の HH:MM 形式で入力してください(例:10:00)'
            );
        }
    };

    input.addEventListener('input', validateTime);
    input.addEventListener('change', validateTime);
    input.addEventListener('invalid', validateTime);

    validateTime();
}

setupTimeValidation(openingTimeInput, '営業開始時刻');
setupTimeValidation(closingTimeInput, '営業終了時刻');
    
    const latitudeInput = document.querySelector(
    'input[name="FestivalStallPlacement[latitude]"]'
);

const longitudeInput = document.querySelector(
    'input[name="FestivalStallPlacement[longitude]"]'
);

function setupCoordinateValidation(input, label, min, max) {
    if (!input) {
        return null;
    }

    input.inputMode = 'decimal';

    const validateCoordinate = function () {
        const value = input.value.trim();

        input.setCustomValidity('');

        /*
         * exact または approximate の場合は
         * 緯度・経度を必須にする。
         */
        if (value === '') {
            if (
                positionSelect &&
                (
                    positionSelect.value === 'exact' ||
                    positionSelect.value === 'approximate'
                )
            ) {
                input.setCustomValidity(
                    label +
                    'は「位置確認済み」または「おおよその位置」を選択した場合は必須です。'
                );

                return;
            }

            const otherInput =
                input === latitudeInput
                    ? longitudeInput
                    : latitudeInput;

            if (
                otherInput &&
                otherInput.value.trim() !== ''
            ) {
                input.setCustomValidity(
                    '緯度と経度は両方入力するか、両方空欄にしてください。'
                );
            }

            return;
        }

        /*
         * 数値形式チェック
         */
        if (!/^-?\d+(\.\d+)?$/.test(value)) {
            input.setCustomValidity(
                label + 'は数値で入力してください。'
            );
            return;
        }

        /*
         * 範囲チェック
         */
        const number = Number(value);

        if (number < min || number > max) {
            input.setCustomValidity(
                label +
                'は' +
                min +
                '〜' +
                max +
                'の範囲で入力してください。'
            );
        }
    };

    input.addEventListener(
        'input',
        validateCoordinate
    );

    input.addEventListener(
        'change',
        validateCoordinate
    );

    input.addEventListener(
        'invalid',
        validateCoordinate
    );

    validateCoordinate();

    /*
     * position_status変更時に
     * 再チェックできるよう関数を返す。
     */
    return validateCoordinate;
}

const validateLatitude =
    setupCoordinateValidation(
        latitudeInput,
        '緯度',
        20,
        46
    );

const validateLongitude =
    setupCoordinateValidation(
        longitudeInput,
        '経度',
        122,
        154
    );

/*
 * 一方の座標を変更した場合、
 * 反対側のペア整合性も再検証する。
 */
if (
    latitudeInput &&
    validateLongitude
) {
    latitudeInput.addEventListener(
        'input',
        validateLongitude
    );

    latitudeInput.addEventListener(
        'change',
        validateLongitude
    );
}

if (
    longitudeInput &&
    validateLatitude
) {
    longitudeInput.addEventListener(
        'input',
        validateLatitude
    );

    longitudeInput.addEventListener(
        'change',
        validateLatitude
    );
}

/*
 * 位置情報の状態を変更した場合、
 * 緯度・経度を再検証する。
 */
if (positionSelect) {
    positionSelect.addEventListener(
        'change',
        function () {
            if (validateLatitude) {
                validateLatitude();
            }

            if (validateLongitude) {
                validateLongitude();
            }
        }
    );
}
    
    const sourceUrlInput = document.querySelector(
    'input[name="FestivalStallPlacement[source_url]"]'
);

if (sourceUrlInput) {
    sourceUrlInput.inputMode = 'url';

    const validateSourceUrl = function () {
        const value = sourceUrlInput.value.trim();

        sourceUrlInput.setCustomValidity('');

        if (value === '') {
            return;
        }

        try {
            const url = new URL(value);

            if (url.protocol !== 'http:' && url.protocol !== 'https:') {
                sourceUrlInput.setCustomValidity(
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            sourceUrlInput.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
        }
    };

    sourceUrlInput.addEventListener('input', validateSourceUrl);
    sourceUrlInput.addEventListener('change', validateSourceUrl);
    sourceUrlInput.addEventListener('invalid', validateSourceUrl);

    validateSourceUrl();
}
    
    const sortOrderInput = document.querySelector(
    'input[name="FestivalStallPlacement[sort_order]"]'
);

if (sortOrderInput) {
    sortOrderInput.inputMode = 'numeric';

    const validateSortOrder = function () {
        const value = sortOrderInput.value.trim();

        sortOrderInput.setCustomValidity('');

        if (value !== '' && !/^\d+$/.test(value)) {
            sortOrderInput.setCustomValidity(
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    };

    sortOrderInput.addEventListener('input', validateSortOrder);
    sortOrderInput.addEventListener('change', validateSortOrder);
    sortOrderInput.addEventListener('invalid', validateSortOrder);

    validateSortOrder();
}
    
});

/**
 * FestivalStallPlacement - 最終確認日の未来日チェック
 */
(function () {
	'use strict';

	function setupLastConfirmedValidation() {
		const dateInputs = document.querySelectorAll(
			'input[name="FestivalStallPlacement[last_confirmed]"]'
		);

		dateInputs.forEach(function (dateInput) {
			if (dateInput.dataset.lastConfirmedValidation === '1') {
				return;
			}

			dateInput.dataset.lastConfirmedValidation = '1';

			function getVisibleInput() {
				const widget = dateInput.closest('.oo-ui-widget');

				if (!widget) {
					return null;
				}

				return widget.querySelector('input[type="text"]');
			}

			function getErrorElement() {
				const widget = dateInput.closest('.oo-ui-widget');

				if (!widget) {
					return null;
				}

				let error = widget.parentNode.querySelector(
					'.stall-last-confirmed-error'
				);

				if (!error) {
					error = document.createElement('div');
					error.className = 'stall-last-confirmed-error';
					error.setAttribute('role', 'alert');
					error.hidden = true;

					widget.insertAdjacentElement('afterend', error);
				}

				return error;
			}

			function showError() {
				const visibleInput = getVisibleInput();
				const error = getErrorElement();

				if (!visibleInput || !error) {
					return;
				}

				let message;

				if (dateInput.validity.rangeOverflow) {
					const maxDate = dateInput.max.replace(/-/g, '/');

					message =
						'未来の日付は入力できません。' +
						maxDate +
						'以前の日付を入力してください。';
				} else {
					message =
						dateInput.validationMessage ||
						'正しい日付を入力してください。';
				}

				error.textContent = message;
				error.hidden = false;

				visibleInput.setAttribute('aria-invalid', 'true');
			}

			function clearError() {
				const visibleInput = getVisibleInput();
				const error = getErrorElement();

				if (error) {
					error.hidden = true;
					error.textContent = '';
				}

				if (visibleInput) {
					visibleInput.removeAttribute('aria-invalid');
				}
			}

			/*
			 * 非表示の date input に対する
			 * ブラウザ標準エラー表示を止める。
			 */
			dateInput.addEventListener('invalid', function (event) {
				event.preventDefault();

				showError();

				const visibleInput = getVisibleInput();

				if (visibleInput) {
					window.setTimeout(function () {
						visibleInput.focus();
					}, 0);
				}
			});

			/*
			 * ユーザーが日付を修正したら
			 * 有効になった時点でエラーを消す。
			 */
const form = dateInput.form;

if (form) {
    function handleDateChange(event) {
        const currentWidget =
            dateInput.closest('.oo-ui-widget');

        if (
            !currentWidget ||
            !currentWidget.contains(event.target)
        ) {
            return;
        }

        window.setTimeout(function () {
            if (dateInput.validity.valid) {
                clearError();
            } else if (
                dateInput.validity.rangeOverflow
            ) {
                showError();
            }
        }, 0);
    }

    form.addEventListener(
        'input',
        handleDateChange
    );

    form.addEventListener(
        'change',
        handleDateChange
    );

    /*
     * Page Forms のカレンダー選択では
     * visible input に blur が発生する。
     * blur は通常バブルしないため capture=true。
     */
    form.addEventListener(
        'blur',
        handleDateChange,
        true
    );
}
		});
	}

	if (document.readyState === 'loading') {
		document.addEventListener(
			'DOMContentLoaded',
			setupLastConfirmedValidation
		);
	} else {
		setupLastConfirmedValidation();
	}

	mw.hook('wikipage.content').add(function () {
		setupLastConfirmedValidation();
	});
})();

/**
 * FestivalStallPlacement
 * Cargo既存レコード候補警告 V2
 *
 * 同じ festival + year + venue + stall があれば
 * 警告と既存ページへのリンクを表示する。
 * 保存自体は禁止しない。
 */
mw.loader.using([
	'mediawiki.api',
	'mediawiki.util'
]).then(function () {
	'use strict';

	const api = new mw.Api();

	function setupDuplicateWarning() {
		const form = document.getElementById('pfForm');

		if (!form) {
			return;
		}

		if (form.dataset.duplicateWarningV2 === '1') {
			return;
		}

		const table = form.querySelector('.formtable');

		if (!table) {
			return;
		}

		form.dataset.duplicateWarningV2 = '1';

		const warning = document.createElement('div');

		warning.className = 'stall-duplicate-warning';
		warning.setAttribute('role', 'status');
		warning.hidden = true;

		/*
		 * 表の中ではなく、表の直前に置く。
		 * 警告表示でフォームの列幅を崩さない。
		 */
		table.insertAdjacentElement('beforebegin', warning);

		let timer = null;
		let requestId = 0;

		function escapeCargo(value) {
			return String(value).replace(/'/g, "''");
		}

		function getField(name) {
			return form.querySelector(
				'[name="FestivalStallPlacement[' +
				name +
				']"]'
			);
		}

		function cargoQuery(tableName, fields, where, limit) {
			return api.get({
				action: 'cargoquery',
				tables: tableName,
				fields: fields,
				where: where,
				limit: limit || 50,
				format: 'json'
			}).then(function (data) {
				if (
					!data ||
					!Array.isArray(data.cargoquery)
				) {
					return [];
				}

				return data.cargoquery.map(function (item) {
					return item.title || item;
				});
			});
		}

		function resolveId(
			tableName,
			idField,
			nameField,
			value
		) {
			if (!value) {
				return Promise.resolve(null);
			}

			if (/^\d+$/.test(value)) {
				return Promise.resolve(value);
			}

			return cargoQuery(
				tableName,
				idField + '=resolved_id',
				nameField +
					"='" +
					escapeCargo(value) +
					"'",
				2
			).then(function (rows) {
				if (rows.length !== 1) {
					console.warn(
						'IDを一意に取得できません:',
						tableName,
						value,
						rows
					);

					return null;
				}

				return String(rows[0].resolved_id);
			});
		}

		function clearWarning() {
			warning.hidden = true;
			warning.replaceChildren();
		}

		function showFailure() {
			warning.replaceChildren();

			const text = document.createElement('div');

			text.textContent =
				'既存データの確認に失敗しました。' +
				'登録はできますが、重複がないかご確認ください。';

			warning.appendChild(text);
			warning.hidden = false;
		}

function showCandidates(rows, venueSpecified) {
	warning.replaceChildren();

const positionLabels = {
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};

	const verificationLabels = {
		verified: '確認済み',
		partially_verified: '一部確認済み',
		unverified: '未確認',
		outdated: '情報が古い可能性あり'
	};
	
const statusLabels = {
    active: '出店中・出店予定',
    cancelled: '出店中止',
    unknown: '未確認'
};

	function displayValue(value, fallback) {
		if (
			value === undefined ||
			value === null ||
			String(value).trim() === ''
		) {
			return fallback || '未確認';
		}

		return String(value);
	}

	function addDetail(container, label, value) {
		const row = document.createElement('div');
		row.className =
			'stall-duplicate-candidate-detail';

		const labelElement =
			document.createElement('span');

		labelElement.className =
			'stall-duplicate-candidate-label';

		labelElement.textContent = label;

		const valueElement =
			document.createElement('span');

		valueElement.className =
			'stall-duplicate-candidate-value';

		valueElement.textContent = value;

		row.appendChild(labelElement);
		row.appendChild(valueElement);

		container.appendChild(row);
	}

	const title = document.createElement('strong');

	title.className =
		'stall-duplicate-warning-title';

	title.textContent =
		venueSpecified
			? (
				'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
				rows.length +
				'件あります。'
			)
			: (
				'⚠ 同じ祭り・開催年・屋台の既存データが' +
				rows.length +
				'件あります。'
			);

	warning.appendChild(title);

	const description =
		document.createElement('p');

	description.className =
		'stall-duplicate-warning-description';

	description.textContent =
		venueSpecified
			? (
				'出店場所が異なる場合は新規登録して構いません。' +
				'下の既存データと同じ場所ではないか確認してください。'
			)
			: (
				'会場未指定のため、会場を問わず候補を確認しています。' +
				'出店場所が異なる場合は新規登録して構いません。' +
				'下の既存データと同じ場所ではないか確認してください。'
			);

	warning.appendChild(description);

	const list = document.createElement('div');

	list.className =
		'stall-duplicate-candidate-list';

	/*
	 * placement_id順に並べる
	 */
	rows.sort(function (a, b) {
		return (
			Number(a.placement_id) -
			Number(b.placement_id)
		);
	});

	rows.forEach(function (row) {
		const card =
			document.createElement('div');

		card.className =
			'stall-duplicate-candidate';

		/*
		 * カード見出し
		 */
		const header =
			document.createElement('div');

		header.className =
			'stall-duplicate-candidate-header';

const heading =
	document.createElement('strong');

heading.textContent =
	'既存の出店情報';

header.appendChild(heading);

		card.appendChild(header);

		/*
		 * 出店場所
		 */
		addDetail(
			card,
			'出店場所',
			displayValue(
				row.location_note,
				'場所メモなし'
			)
		);

		/*
		 * 位置状態
		 */
		addDetail(
			card,
			'位置状態',
			positionLabels[
				row.position_status
			] ||
			displayValue(
				row.position_status,
				'位置未確認'
			)
		);

		/*
		 * 緯度・経度
		 */
		let coordinates =
			'位置情報なし';

		if (
			row.latitude !== undefined &&
			row.latitude !== null &&
			String(row.latitude).trim() !== '' &&
			row.longitude !== undefined &&
			row.longitude !== null &&
			String(row.longitude).trim() !== ''
		) {
			coordinates =
				String(row.latitude) +
				', ' +
				String(row.longitude);
		}

		addDetail(
			card,
			'緯度・経度',
			coordinates
		);

/*
 * 位置精度
 */
let accuracy = '未確認';

if (
	row.position_accuracy_m !== undefined &&
	row.position_accuracy_m !== null &&
	String(row.position_accuracy_m).trim() !== ''
) {
	accuracy =
		String(row.position_accuracy_m) +
		' m';
}

addDetail(
	card,
	'位置精度',
	accuracy
);

/*
 * 出店状態
 */
addDetail(
	card,
	'出店状態',
	statusLabels[
		row.status
	] ||
	displayValue(
		row.status,
		'未確認'
	)
);

/*
 * 最終確認日
 */
let lastConfirmed = '未確認';

if (
	row.last_confirmed !== undefined &&
	row.last_confirmed !== null &&
	String(row.last_confirmed).trim() !== ''
) {
	lastConfirmed =
		String(row.last_confirmed)
			.replace(/-/g, '/');
}

addDetail(
	card,
	'最終確認日',
	lastConfirmed
);

		/*
		 * 確認状態
		 */
		addDetail(
			card,
			'確認状態',
			verificationLabels[
				row.verification_status
			] ||
			displayValue(
				row.verification_status,
				'未確認'
			)
		);

		/*
		 * 既存ページへのリンク
		 */
		const actions =
			document.createElement('div');

		actions.className =
			'stall-duplicate-candidate-actions';

		const link =
			document.createElement('a');

		link.href =
			mw.util.getUrl(row.page_name);

		link.target = '_blank';
		link.rel = 'noopener';

		link.textContent =
			'既存データを確認';

		actions.appendChild(link);
		card.appendChild(actions);

		list.appendChild(card);
	});

	warning.appendChild(list);

	const footer =
		document.createElement('div');

	footer.className =
		'stall-duplicate-warning-footer';

	footer.textContent =
		'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';

	warning.appendChild(footer);

	warning.hidden = false;
}

function checkDuplicates() {
	const currentRequest = ++requestId;

	/*
	 * 毎回現在のinput/selectを取得する。
	 * Page Formsが要素を作り直しても対応できる。
	 */
	const stall = getField('stall_id');
	const festival = getField('festival_id');
	const venue = getField('venue_id');
	const year = getField('year');

	if (
		!stall ||
		!festival ||
		!venue ||
		!year
	) {
		clearWarning();
		return;
	}

	const stallValue = stall.value.trim();
	const festivalValue = festival.value.trim();
	const venueValue = venue.value.trim();
	const yearValue = year.value.trim();

	if (
		!stallValue ||
		!festivalValue ||
		!/^\d{4}$/.test(yearValue)
	) {
		clearWarning();
		return;
	}

	/*
	 * async / await は使わず、
	 * Promise の then() で処理する。
	 */
	Promise.all([
		resolveId(
			'Stalls',
			'stall_id',
			'name',
			stallValue
		),
		resolveId(
			'Festivals',
			'festival_id',
			'name',
			festivalValue
		),
		venueValue !== ''
			? resolveId(
				'Venues',
				'venue_id',
				'_pageName',
				venueValue
			)
			: Promise.resolve(null)
	])
	.then(function (ids) {
		if (currentRequest !== requestId) {
			return null;
		}

		if (
			!ids[0] ||
			!ids[1] ||
			(
				venueValue !== '' &&
				!ids[2]
			)
		) {
			clearWarning();
			return null;
		}

		let where =
			'festival_id=' +
			ids[1] +
			' AND year=' +
			yearValue +
			' AND stall_id=' +
			ids[0];

		if (ids[2]) {
			where +=
				' AND venue_id=' +
				ids[2];
		}

return cargoQuery(
	'FestivalStallPlacements',
	'placement_id=placement_id,' +
		'location_note=location_note,' +
		'latitude=latitude,' +
		'longitude=longitude,' +
		'position_status=position_status,' +
		'position_accuracy_m=position_accuracy_m,' +
		'status=status,' +
		'verification_status=verification_status,' +
		'last_confirmed=last_confirmed,' +
		'_pageName=page_name',
	where,
	50
).then(function (rows) {
			if (currentRequest !== requestId) {
				return;
			}

			console.log(
				'FestivalStallPlacement候補:',
				where,
				rows
			);

			if (rows.length === 0) {
				clearWarning();
				return;
			}

			/*
			 * 今回は警告のみ。
			 * 同条件の既存データをすべて表示する。
			 */
			const rawPageName =
				String(
					mw.config.get('wgPageName') ||
					''
				);

			const formEditMarker =
				'/FestivalStallPlacement/';

			const markerIndex =
				rawPageName.indexOf(
					formEditMarker
				);

			const currentPlacementPage =
				markerIndex >= 0
					? rawPageName
						.slice(
							markerIndex +
							formEditMarker.length
						)
						.replace(/_/g, ' ')
						.trim()
					: '';

			const filteredRows =
				currentPlacementPage
					? rows.filter(function (row) {
						return (
							String(
								row.page_name ||
								''
							)
								.replace(/_/g, ' ')
								.trim() !==
							currentPlacementPage
						);
					})
					: rows;

			if (filteredRows.length === 0) {
				clearWarning();
				return;
			}

			showCandidates(
				filteredRows,
				venueValue !== ''
			);
		});
	})
	.catch(function (error) {
		console.error(
			'FestivalStallPlacement候補確認エラー:',
			error
		);

		showFailure();
	});
}

		function scheduleCheck() {
			window.clearTimeout(timer);

			timer = window.setTimeout(
				checkDuplicates,
				300
			);
		}

		/*
		 * form自身へイベントを設定する。
		 * dropdownが後から置き換わっても拾える。
		 */
		form.addEventListener('change', function (event) {
			const name = event.target.name || '';

			if (
				name ===
					'FestivalStallPlacement[stall_id]' ||
				name ===
					'FestivalStallPlacement[festival_id]' ||
				name ===
					'FestivalStallPlacement[venue_id]' ||
				name ===
					'FestivalStallPlacement[year]'
			) {
				scheduleCheck();
			}
		});

		form.addEventListener('input', function (event) {
			if (
				event.target.name ===
				'FestivalStallPlacement[year]'
			) {
				scheduleCheck();
			}
		});

		scheduleCheck();
	}

	if (document.readyState === 'loading') {
		document.addEventListener(
			'DOMContentLoaded',
			setupDuplicateWarning
		);
	} else {
		setupDuplicateWarning();
	}

	mw.hook('pf.formSetupAfter').add(
		setupDuplicateWarning
	);
});

/*
 * FestivalStallMenuOffering
 * 入力検証・日本語表示
 */
(function () {
    'use strict';

    var FORM_ID = 'pfForm';

    var availabilityLabels = {
        available: '販売中',
        unknown: '未確認'
    };

    var verificationLabels = {
        verified: '確認済み',
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };

    function isOfferingField(element) {
        return !!(
            element &&
            element.name &&
            element.name.indexOf(
                'FestivalStallMenuOffering['
            ) === 0
        );
    }

    function isTemplateField(element) {
        return !!(
            element &&
            element.name &&
            element.name.indexOf('[num]') !== -1
        );
    }

    function fieldNameEndsWith(element, suffix) {
        return !!(
            element &&
            element.name &&
            element.name.slice(-suffix.length) === suffix
        );
    }

    function localizeSelect(select, labels) {
        if (!select) {
            return;
        }

        Array.from(select.options).forEach(
            function (option) {
                if (
                    Object.prototype.hasOwnProperty.call(
                        labels,
                        option.value
                    ) &&
                    option.textContent !==
                        labels[option.value]
                ) {
                    option.textContent =
                        labels[option.value];
                }
            }
        );
    }

    function validatePrice(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '価格は0以上の整数で入力してください(例:600)'
            );
        }
    }

    function validateServingQuantity(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (value === '') {
            return;
        }

        if (
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
        ) {
            input.setCustomValidity(
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
            );
        }
    }

    function validateLimitedQuantity(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '限定数量は0以上の整数で入力してください(例:100)'
            );
        }
    }

    function validateSortOrder(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    }

    function validateSourceUrl(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (value === '') {
            return;
        }

        try {
            var url = new URL(value);

            if (
                url.protocol !== 'http:' &&
                url.protocol !== 'https:'
            ) {
                input.setCustomValidity(
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            input.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
        }
    }

    function validateLastConfirmed(input) {
        var value = input.value;
        var max = input.max;

        input.setCustomValidity('');

        if (
            value !== '' &&
            max !== '' &&
            value > max
        ) {
            input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
    }

    function getVisibleDateInput(dateInput) {
        var widget =
            dateInput.closest('.oo-ui-widget');

        if (!widget) {
            return null;
        }

        return widget.querySelector(
            'input[type="text"]'
        );
    }

    function getDateErrorElement(dateInput) {
        var widget =
            dateInput.closest('.oo-ui-widget');

        if (!widget) {
            return null;
        }

        var next =
            widget.nextElementSibling;

        if (
            next &&
            next.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            return next;
        }

        var error =
            document.createElement('div');

        /*
         * 既存の最終確認日エラー用CSSも利用する。
         */
        error.className =
            'stall-last-confirmed-error ' +
            'stall-offering-last-confirmed-error';

        error.setAttribute(
            'role',
            'alert'
        );

        error.hidden = true;

        widget.insertAdjacentElement(
            'afterend',
            error
        );

        return error;
    }

    function showDateError(dateInput) {
        var visibleInput =
            getVisibleDateInput(dateInput);

        var error =
            getDateErrorElement(dateInput);

        if (!error) {
            return;
        }

        var maxDate =
            dateInput.max
                ? dateInput.max.replace(/-/g, '/')
                : '';

        if (
            dateInput.validity.rangeOverflow ||
            (
                dateInput.value &&
                dateInput.max &&
                dateInput.value > dateInput.max
            )
        ) {
            error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
            error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }

        error.hidden = false;

        if (visibleInput) {
            visibleInput.setAttribute(
                'aria-invalid',
                'true'
            );
        }
    }

    function clearDateError(dateInput) {
        var visibleInput =
            getVisibleDateInput(dateInput);

        var widget =
            dateInput.closest('.oo-ui-widget');

        var error = null;

        if (
            widget &&
            widget.nextElementSibling &&
            widget.nextElementSibling.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            error =
                widget.nextElementSibling;
        }

        if (error) {
            error.hidden = true;
            error.textContent = '';
        }

        if (visibleInput) {
            visibleInput.removeAttribute(
                'aria-invalid'
            );
        }
    }

    function getLimitedQuantityInput(
        checkbox,
        form
    ) {
        if (!checkbox || !checkbox.name) {
            return null;
        }

        var quantityName =
            checkbox.name.replace(
                /\[limited\]\[value\]$/,
                '[limited_quantity]'
            );

        return Array.from(
            form.querySelectorAll(
                'input[name^="FestivalStallMenuOffering["]'
            )
        ).find(
            function (input) {
                return input.name === quantityName;
            }
        ) || null;
    }

    function updateLimitedState(
        checkbox,
        form,
        clearWhenOff
    ) {
        var quantityInput =
            getLimitedQuantityInput(
                checkbox,
                form
            );

        if (!quantityInput) {
            return;
        }

        if (checkbox.checked) {
            quantityInput.disabled = false;
            quantityInput.removeAttribute(
                'aria-disabled'
            );
        } else {
            if (clearWhenOff) {
                quantityInput.value = '';
            }

            quantityInput.setCustomValidity('');
            quantityInput.disabled = true;
            quantityInput.setAttribute(
                'aria-disabled',
                'true'
            );
        }
    }

    function validateField(element) {
        if (
            !isOfferingField(element) ||
            isTemplateField(element)
        ) {
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[price]'
            )
        ) {
            validatePrice(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[serving_quantity]'
            )
        ) {
            validateServingQuantity(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[limited_quantity]'
            )
        ) {
            validateLimitedQuantity(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[source_url]'
            )
        ) {
            validateSourceUrl(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[last_confirmed]'
            )
        ) {
            validateLastConfirmed(element);

            if (element.validity.valid) {
                clearDateError(element);
            }

            return;
        }
    }

    function initializeFields(form) {
        /*
         * 販売状態を日本語化。
         * [num]も変更しておくことで、
         * 後から追加されるmultipleにも反映される。
         */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[availability]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    availabilityLabels
                );
            }
        );

        /*
         * 確認状態を日本語化。
         */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[verification_status]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    verificationLabels
                );
            }
        );

        /*
         * 数値入力向けキーボード。
         */
        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[price]"],' +
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited_quantity]"],' +
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[sort_order]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'numeric';
            }
        );

        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[serving_quantity]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'decimal';
            }
        );

        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[source_url]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'url';
            }
        );

        /*
         * 限定数量欄のON/OFF。
         */
        form.querySelectorAll(
            'input[type="checkbox"]' +
            '[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited][value]"]'
        ).forEach(
            function (checkbox) {
                updateLimitedState(
                    checkbox,
                    form,
                    false
                );
            }
        );

        /*
         * 現在値を一度検証。
         * [num]は除外。
         */
        form.querySelectorAll(
            '[name^="FestivalStallMenuOffering["]'
        ).forEach(
            function (element) {
                validateField(element);
            }
        );
    }

    function setupOfferingValidation() {
        var form =
            document.getElementById(
                FORM_ID
            );

        if (!form) {
            return;
        }

        /*
         * wikipage.content 等で再度呼ばれても
         * イベントを二重登録しない。
         */
        if (
            form.dataset
                .offeringValidationInitialized ===
            '1'
        ) {
            initializeFields(form);
            return;
        }

        form.dataset
            .offeringValidationInitialized =
            '1';

        /*
         * multipleで後から追加された項目にも効くよう
         * form側でイベント委譲。
         */
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
            }
        );

        form.addEventListener(
            'change',
            function (event) {
                var target =
                    event.target;

                if (!isOfferingField(target)) {
                    return;
                }

                if (
                    target.type === 'checkbox' &&
                    fieldNameEndsWith(
                        target,
                        '[limited][value]'
                    )
                ) {
                    updateLimitedState(
                        target,
                        form,
                        true
                    );
                }

                validateField(target);
            }
        );

/*
 * Page Forms のカレンダー選択では、
 * visible input に blur が発生する場合がある。
 * 対応する非表示 date input を取得して再検証する。
 */
form.addEventListener(
    'blur',
    function (event) {
var target = event.target;

if (
    !target ||
    typeof target.closest !== 'function'
) {
    return;
}

var widget =
    target.closest('.oo-ui-widget');

        if (!widget) {
            return;
        }

        var dateInput =
            widget.querySelector(
                'input[type="date"]' +
                '[name^="FestivalStallMenuOffering["]' +
                '[name$="[last_confirmed]"]'
            );

        if (
            !dateInput ||
            isTemplateField(dateInput)
        ) {
            return;
        }

        window.setTimeout(
            function () {
                validateField(dateInput);

                if (dateInput.validity.valid) {
                    clearDateError(dateInput);
                } else {
                    showDateError(dateInput);
                }
            },
            0
        );
    },
    true
);

        /*
         * invalidイベントは通常bubbleしないため
         * capture=trueで取得する。
         */
        form.addEventListener(
            'invalid',
            function (event) {
                var target =
                    event.target;

                if (
                    !isOfferingField(target) ||
                    isTemplateField(target)
                ) {
                    return;
                }

                validateField(target);

                if (
                    fieldNameEndsWith(
                        target,
                        '[last_confirmed]'
                    )
                ) {
                    event.preventDefault();

                    showDateError(target);

                    var visibleInput =
                        getVisibleDateInput(
                            target
                        );

                    if (visibleInput) {
                        window.setTimeout(
                            function () {
                                visibleInput.focus();
                            },
                            0
                        );
                    }
                }
            },
            true
        );

        /*
         * 「販売商品を追加」でDOMが増えた場合の初期化。
         */
        var mutationTimer = null;

        var observer =
            new MutationObserver(
                function () {
                    window.clearTimeout(
                        mutationTimer
                    );

                    mutationTimer =
                        window.setTimeout(
                            function () {
                                initializeFields(
                                    form
                                );
                            },
                            100
                        );
                }
            );

        observer.observe(
            form,
            {
                childList: true,
                subtree: true
            }
        );

        initializeFields(form);
    }

    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingValidation
        );
    } else {
        setupOfferingValidation();
    }

    mw.hook(
        'wikipage.content'
    ).add(
        setupOfferingValidation
    );

    mw.hook(
        'pf.formSetupAfter'
    ).add(
        setupOfferingValidation
    );

})();


mw.loader.using('mediawiki.api').then(function () {
    'use strict';

    if (window.__festivalStallMenuFilterInitialized) {
        return;
    }

    window.__festivalStallMenuFilterInitialized = true;

    const STALL_SELECTOR =
        'select[name="FestivalStallPlacement[stall_id]"]';

    const MENU_SELECTOR =
        'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';

    const TEMPLATE_MENU_SELECTOR =
        'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';

    const api = new mw.Api();

    let requestSerial = 0;
    let observerTimer = null;
    let applying = false;

    const menuCache = {};

/*
 * FestivalStallPlacement フォーム以外では
 * この連動機能を起動しない。
 */
const stallSelect =
    document.querySelector(STALL_SELECTOR);

if (!stallSelect) {
    return;
}

/*
 * Page Formsの雛形が持つ全商品optionを最初に保存
 */
const templateSelect =
    document.querySelector(TEMPLATE_MENU_SELECTOR);

if (!templateSelect) {
    console.error(
        '販売商品の雛形SELECTが見つかりません。'
    );
    return;
}

    const masterOptions =
        [...templateSelect.options].map(
            function (option) {
                return option.cloneNode(true);
            }
        );

    function cargoQuote(value) {
        return "'" + String(value)
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }

    function cargoRows(res) {
        return (res.cargoquery || []).map(
            function (row) {
                return row.title || {};
            }
        );
    }

    function getRealMenuSelects() {
        return [
            ...document.querySelectorAll(
                MENU_SELECTOR
            )
        ].filter(function (select) {
            return !select.name.includes('[num]');
        });
    }

    function resolveStallId(stallName) {

        return api.get({
            action: 'cargoquery',
            format: 'json',
            tables: 'Stalls',
            fields:
                'stall_id=stall_id,' +
                'name=name',
            where:
                'name=' +
                cargoQuote(stallName),
            limit: 20
        }).then(function (res) {

            const rows =
                cargoRows(res);

            if (rows.length === 1) {
                return rows[0].stall_id;
            }

            /*
             * 同名表示が
             * 名前 (ID)
             * になっている場合
             */
            const match =
                String(stallName)
                    .match(/\((\d+)\)$/);

            if (!match) {
                throw new Error(
                    '屋台を1件に特定できません: ' +
                    stallName
                );
            }

            return match[1];
        });
    }

    function loadMenus(stallId) {

        const key =
            String(stallId);

        if (menuCache[key]) {
            return Promise.resolve(
                menuCache[key]
            );
        }

        return api.get({
            action: 'cargoquery',
            format: 'json',
            tables: 'StallMenuItems',
fields:
    'menu_item_id=menu_item_id,' +
    'stall_id=stall_id,' +
    'name=name,' +
    'status=status',
            where:
                'stall_id=' +
                Number(stallId) +
                " AND status='active'",
            order_by:
    'menu_item_id',
            limit: 100
        }).then(function (res) {

            const rows =
                cargoRows(res);

            menuCache[key] =
                rows;

            return rows;
        });
    }

    function optionBelongsToMenu(
        option,
        menu
    ) {

        const name =
            String(menu.name || '');

        const id =
            String(
                menu.menu_item_id || ''
            );

        const value =
            String(option.value || '');

        const text =
            String(
                option.textContent || ''
            );

        /*
         * 商品名が一意
         */
        if (
            value === name ||
            text === name
        ) {
            return true;
        }

        /*
         * Page Formsによる
         * 同名商品の識別表示
         *
         * たこ焼き (1)
         * たこ焼き (3)
         */
        const mapped =
            name + ' (' + id + ')';

        return (
            value === mapped ||
            text === mapped
        );
    }

    function makeOptions(menus) {

        const options = [];

        /*
         * 空欄
         */
        const blank =
            masterOptions.find(
                function (option) {
                    return (
                        option.value === ''
                    );
                }
            );

        if (blank) {
            options.push(
                blank.cloneNode(true)
            );
        } else {
            options.push(
                new Option('', '')
            );
        }

        menus.forEach(
            function (menu) {

                const option =
                    masterOptions.find(
                        function (candidate) {
                            return optionBelongsToMenu(
                                candidate,
                                menu
                            );
                        }
                    );

                if (option) {
                    options.push(
                        option.cloneNode(true)
                    );
                } else {
                    console.warn(
                        'Page Formsのoptionを特定できません:',
                        menu
                    );
                }
            }
        );

        return options;
    }

    function optionSignature(select) {

        return [...select.options]
            .map(function (option) {
                return (
                    option.value +
                    '::' +
                    option.textContent
                );
            })
            .join('||');
    }

    function filterMenuSelects(
        menus,
        clearSelection
    ) {

        const desiredTemplate =
            makeOptions(menus);

        const desiredSignature =
            desiredTemplate
                .map(function (option) {
                    return (
                        option.value +
                        '::' +
                        option.textContent
                    );
                })
                .join('||');

        applying = true;

        getRealMenuSelects().forEach(
            function (select) {

                const previousValue =
                    select.value;

                /*
                 * すでに正しい候補なら
                 * DOMを触らない
                 */
                if (
                    optionSignature(select) ===
                    desiredSignature
                ) {
                    if (clearSelection &&
                        select.value !== '') {

                        select.value = '';

                        if (window.jQuery) {
                            jQuery(select)
                                .trigger('change');
                        }
                    }

                    return;
                }

                const newOptions =
                    desiredTemplate.map(
                        function (option) {
                            return option
                                .cloneNode(true);
                        }
                    );

                select.replaceChildren(
                    ...newOptions
                );

                if (!clearSelection) {

                    const exists =
                        [...select.options]
                            .some(
                                function (option) {
                                    return (
                                        option.value ===
                                        previousValue
                                    );
                                }
                            );

                    if (exists) {
                        select.value =
                            previousValue;
                    }
                }

                if (clearSelection) {
                    select.value = '';
                }

                if (window.jQuery) {
                    jQuery(select)
                        .trigger('change');
                }
            }
        );

        /*
         * MutationObserverに
         * 自分自身の変更を拾わせない
         */
        setTimeout(
            function () {
                applying = false;
            },
            0
        );
    }

    function refreshMenus(
        clearSelection
    ) {

        const stall =
            document.querySelector(
                STALL_SELECTOR
            );

if (!stall) {
    return;
}

if (!stall.value) {
    /*
     * 屋台が未選択なら、
     * 進行中の古い非同期処理を無効化し、
     * 商品候補を空欄だけに戻す。
     */
    ++requestSerial;

    filterMenuSelects(
        [],
        true
    );

    return;
}

        const serial =
            ++requestSerial;

        const stallName =
            stall.value;

        resolveStallId(
            stallName
        )
        .then(function (stallId) {

            if (
                serial !==
                requestSerial
            ) {
                return null;
            }

            console.log(
                '[屋台→商品V2]',
                stallName,
                '→ stall_id=' +
                stallId
            );

            return loadMenus(
                stallId
            );

        })
        .then(function (menus) {

            if (
                !menus ||
                serial !==
                    requestSerial
            ) {
                return;
            }

            console.log(
                '[販売商品候補V2]',
                menus
            );

            filterMenuSelects(
                menus,
                clearSelection
            );

        })
        .catch(function (err) {

            console.error(
                '[屋台→商品V2] エラー:',
                err
            );
        });
    }

    /*
     * Page Formsによる
     * option再生成を検出
     */
    function mutationTouchesMenus(
        mutation
    ) {

        const target =
            mutation.target;

        if (
            target.nodeType === 1 &&
            target.matches &&
            target.matches(MENU_SELECTOR)
        ) {
            return true;
        }

        for (
            const node of
            mutation.addedNodes
        ) {

            if (
                node.nodeType !== 1
            ) {
                continue;
            }

            if (
                node.matches &&
                node.matches(MENU_SELECTOR)
            ) {
                return true;
            }

            if (
                node.querySelector &&
                node.querySelector(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }

            /*
             * SELECTの中にOPTIONが追加された
             */
            if (
                node.tagName === 'OPTION' &&
                node.parentElement &&
                node.parentElement.matches &&
                node.parentElement.matches(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }
        }

        return false;
    }

    const observer =
        new MutationObserver(
            function (mutations) {

                if (applying) {
                    return;
                }

                const touched =
                    mutations.some(
                        mutationTouchesMenus
                    );

                if (!touched) {
                    return;
                }

                clearTimeout(
                    observerTimer
                );

                /*
                 * Page Formsの再初期化が
                 * 完了してから実行
                 */
                observerTimer =
                    setTimeout(
                        function () {
                            refreshMenus(false);
                        },
                        250
                    );
            }
        );

    const form =
        document.getElementById(
            'pfForm'
        ) || document.body;

    observer.observe(
        form,
        {
            childList: true,
            subtree: true
        }
    );

    /*
     * 屋台変更
     */
    const stall =
        document.querySelector(
            STALL_SELECTOR
        );

    function onStallChange() {
        refreshMenus(true);
    }

        stall.addEventListener(
        'change',
        onStallChange
    );

    /*
     * 初期表示
     */
    refreshMenus(false);

        console.log(
        '屋台→販売商品連動を初期化しました。'
    );

});

/*
 * FestivalStallMenuOffering
 * 同一Placement内の商品重複警告
 *
 * 保存は禁止しない。
 */
(function () {
    'use strict';

    const MENU_SELECTOR =
        'select[name^="FestivalStallMenuOffering["]' +
        '[name$="[menu_item_id]"]';

    function setupOfferingDuplicateWarning() {
        const form =
            document.getElementById('pfForm');

        if (!form) {
            return;
        }

        /*
         * FestivalStallPlacementフォームだけを対象にする。
         */
        if (
            !form.querySelector(
                '[name="FestivalStallPlacement[stall_id]"]'
            )
        ) {
            return;
        }

        function getMenuSelects() {
            return [
                ...form.querySelectorAll(
                    MENU_SELECTOR
                )
            ].filter(function (select) {
                return !select.name.includes('[num]');
            });
        }

        function getWarning() {
            let warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );

            if (warning) {
                return warning;
            }

            const firstSelect =
                getMenuSelects()[0];

            if (!firstSelect) {
                return null;
            }

            warning =
                document.createElement('div');

            warning.className =
                'stall-offering-duplicate-warning';

            warning.setAttribute(
                'role',
                'status'
            );

            warning.hidden = true;

            warning.style.marginTop = '8px';
            warning.style.padding = '10px';
            warning.style.border =
                '1px solid #a2a9b1';
            warning.style.borderRadius = '4px';

/*
 * 警告は個別の商品行ではなく、
 * 販売商品multiple全体の上部に表示する。
 */
const wrapper =
    firstSelect.closest(
        '.multipleTemplateWrapper'
    );

const list =
    wrapper
        ? wrapper.querySelector(
            '.multipleTemplateList'
        )
        : null;

if (list) {
    list.insertAdjacentElement(
        'beforebegin',
        warning
    );
} else {
    const container =
        firstSelect.closest('fieldset') ||
        firstSelect.closest('td') ||
        firstSelect.parentNode;

    container.insertBefore(
        warning,
        container.firstChild
    );
}

            return warning;
        }

        function clearWarning() {
            const warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );

            if (!warning) {
                return;
            }

            warning.hidden = true;
            warning.textContent = '';
        }

        function checkDuplicates() {
            const selects =
                getMenuSelects();

            const counts = {};

            selects.forEach(function (select) {
                const value =
                    String(
                        select.value || ''
                    ).trim();

                if (!value) {
                    return;
                }

                counts[value] =
                    (counts[value] || 0) + 1;
            });

            const duplicates =
                Object.keys(counts).filter(
                    function (value) {
                        return counts[value] > 1;
                    }
                );

            if (duplicates.length === 0) {
                clearWarning();
                return;
            }

            const warning =
                getWarning();

            if (!warning) {
                return;
            }

            warning.textContent = '';

            const title =
                document.createElement('strong');

            title.textContent =
                '同じ販売商品が複数回選択されています。';

            warning.appendChild(title);

            const detail =
                document.createElement('div');

            detail.textContent =
                duplicates.join('、') +
                ' が重複しています。' +
                '重複登録でないか確認してください。' +
                '保存自体は禁止しません。';

            warning.appendChild(detail);

            warning.hidden = false;
        }

        /*
         * multipleで後から追加された行にも対応。
         */
        if (
            form.dataset
                .offeringDuplicateWarning !== '1'
        ) {
            form.dataset
                .offeringDuplicateWarning = '1';

/*
 * Page Forms / Select2 は
 * jQueryのchangeを使う場合があるため、
 * jQuery側でイベント委譲する。
 */
if (window.jQuery) {
    jQuery(form).on(
        'change.offeringDuplicateWarning',
        MENU_SELECTOR,
        function () {
            window.setTimeout(
                checkDuplicates,
                0
            );
        }
    );
} else {
    /*
     * jQueryが無い場合のフォールバック。
     */
    form.addEventListener(
        'change',
        function (event) {
            if (
                event.target.matches &&
                event.target.matches(
                    MENU_SELECTOR
                )
            ) {
                window.setTimeout(
                    checkDuplicates,
                    0
                );
            }
        }
    );
}

            const observer =
                new MutationObserver(
                    function () {
                        window.setTimeout(
                            checkDuplicates,
                            0
                        );
                    }
                );

            observer.observe(
                form,
                {
                    childList: true,
                    subtree: true
                }
            );
        }

        checkDuplicates();
    }

    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingDuplicateWarning
        );
    } else {
        setupOfferingDuplicateWarning();
    }

    mw.hook(
        'wikipage.content'
    ).add(
        setupOfferingDuplicateWarning
    );

})();

/*
 * StallMenuItem
 * 入力検証・状態日本語化
 */
(function () {
    'use strict';

    function setupStallMenuItemValidation() {
        const form = document.getElementById('pfForm');

        if (!form) {
            return;
        }

        /*
         * StallMenuItemフォーム以外では何もしない。
         */
        const nameInput = form.querySelector(
            'input[name="StallMenuItem[name]"]'
        );

        if (!nameInput) {
            return;
        }

        /*
         * 二重初期化防止
         */
        if (
            form.dataset.stallMenuItemValidationInitialized === '1'
        ) {
            return;
        }

        form.dataset.stallMenuItemValidationInitialized = '1';

        /*
         * =====================================
         * 状態を日本語表示
         * =====================================
         */
        const statusLabels = {
            active: '取扱中',
            inactive: '一時停止',
            discontinued: '取扱終了',
            unknown: '未確認'
        };

        const statusSelect = form.querySelector(
            'select[name="StallMenuItem[status]"]'
        );

        if (statusSelect) {
            Array.from(statusSelect.options).forEach(
                function (option) {
                    if (statusLabels[option.value]) {
                        option.textContent =
                            statusLabels[option.value];
                    }
                }
            );
        }


        console.log(
            '商品マスター入力チェックを初期化しました。'
        );
    }

    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemValidation
        );
    } else {
        setupStallMenuItemValidation();
    }

    mw.hook('wikipage.content').add(
        setupStallMenuItemValidation
    );

})();

/*
 * StallMenuItem
 * 同一屋台 + 同一商品名の重複警告
 *
 * 保存は禁止しない。
 */
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';

    const api = new mw.Api();

    function cargoQuote(value) {
        return "'" + String(value)
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }

    function cargoRows(response) {
        return (response.cargoquery || []).map(
            function (row) {
                return row.title || row;
            }
        );
    }

    function cargoQuery(
        tables,
        fields,
        where,
        limit
    ) {
        return api.get({
            action: 'cargoquery',
            format: 'json',
            tables: tables,
            fields: fields,
            where: where,
            limit: String(limit || 20)
        }).then(
            function (response) {
                return cargoRows(response);
            }
        );
    }

    function normalizePageName(value) {
        return String(value || '')
            .replace(/_/g, ' ')
            .trim();
    }

    function setupStallMenuItemDuplicateWarning() {
        const form =
            document.getElementById('pfForm');

        if (!form) {
            return;
        }

        /*
         * StallMenuItemフォームだけを対象にする。
         */
        const stallSelect = form.querySelector(
            'select[name="StallMenuItem[stall_id]"]'
        );

        const nameInput = form.querySelector(
            'input[name="StallMenuItem[name]"]'
        );

        if (!stallSelect || !nameInput) {
            return;
        }

        /*
         * 二重初期化防止
         */
        if (
            form.dataset
                .stallMenuItemDuplicateWarning ===
            '1'
        ) {
            return;
        }

        form.dataset
            .stallMenuItemDuplicateWarning =
            '1';

        /*
         * 警告表示欄
         */
        const warning =
            document.createElement('div');

        warning.className =
            'stall-menu-item-duplicate-warning';

        warning.setAttribute(
            'role',
            'status'
        );

        warning.hidden = true;

        warning.style.marginTop = '8px';
        warning.style.padding = '10px';
        warning.style.border = '1px solid #a2a9b1';
        warning.style.borderRadius = '4px';

        const container =
            nameInput.closest('td') ||
            nameInput.parentNode;

        container.appendChild(warning);

        let timer = null;
        let requestSerial = 0;

        /*
         * Page Formsのmappingでは
         * SELECT.valueが屋台名になる場合があるため、
         * Cargoからstall_idを解決する。
         */
        function resolveStallId() {
            const rawValue =
                String(
                    stallSelect.value || ''
                ).trim();

            if (!rawValue) {
                return Promise.resolve('');
            }

            /*
             * 数値ならそのまま使用。
             */
            if (/^\d+$/.test(rawValue)) {
                return Promise.resolve(
                    rawValue
                );
            }

            const selectedOption =
                stallSelect.options[
                    stallSelect.selectedIndex
                ];

            const selectedText =
                selectedOption
                    ? selectedOption.textContent.trim()
                    : '';

            const names = [];

            if (rawValue) {
                names.push(rawValue);
            }

            if (
                selectedText &&
                names.indexOf(selectedText) === -1
            ) {
                names.push(selectedText);
            }

            if (names.length === 0) {
                return Promise.resolve('');
            }

            const where = names.map(
                function (name) {
                    return (
                        'name=' +
                        cargoQuote(name)
                    );
                }
            ).join(' OR ');

            return cargoQuery(
                'Stalls',
                'stall_id=stall_id,' +
                    'name=stall_name',
                where,
                10
            ).then(
                function (rows) {
                    if (!rows.length) {
                        return '';
                    }

                    return String(
                        rows[0].stall_id || ''
                    );
                }
            );
        }

        function clearWarning() {
            warning.hidden = true;
            warning.textContent = '';
        }

        function showWarning(rows) {
            warning.textContent = '';

            const title =
                document.createElement('strong');

            title.textContent =
                '同じ屋台に同名の商品がすでに登録されています。';

            warning.appendChild(title);

            const text =
                document.createElement('div');

            text.textContent =
                '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';

            warning.appendChild(text);

            const list =
                document.createElement('ul');

            rows.forEach(
                function (row) {
                    const item =
                        document.createElement('li');

                    const link =
                        document.createElement('a');

                    link.href =
                        mw.util.getUrl(
                            row.page_name
                        );

                    link.textContent =
                        (
                            row.menu_name ||
                            '商品'
                        ) +
                        '(商品ID: ' +
                        row.menu_item_id +
                        ')';

                    link.target = '_blank';

                    item.appendChild(link);
                    list.appendChild(item);
                }
            );

            warning.appendChild(list);
            warning.hidden = false;
        }

        function checkDuplicate() {
            const menuName =
                nameInput.value.trim();

            if (
                !stallSelect.value ||
                !menuName
            ) {
                clearWarning();
                return;
            }

            const currentRequest =
                ++requestSerial;

            resolveStallId().then(
                function (stallId) {
                    if (
                        currentRequest !==
                        requestSerial
                    ) {
                        return null;
                    }

                    if (!stallId) {
                        clearWarning();
                        return null;
                    }

                    return cargoQuery(
                        'StallMenuItems',
                        'menu_item_id=menu_item_id,' +
                            'name=menu_name,' +
                            '_pageName=page_name',
                        'stall_id=' +
                            stallId +
                            ' AND name=' +
                            cargoQuote(
                                menuName
                            ),
                        20
                    );
                }
            ).then(
                function (rows) {
                    if (
                        rows === null ||
                        rows === undefined
                    ) {
                        return;
                    }

                    if (
                        currentRequest !==
                        requestSerial
                    ) {
                        return;
                    }

                    /*
                     * 編集画面では
                     * 自分自身を重複候補から除外。
                     */
                    const currentPage =
                        normalizePageName(
                            mw.config.get(
                                'wgPageName'
                            )
                        );

                    const duplicates =
                        rows.filter(
                            function (row) {
                                return (
                                    normalizePageName(
                                        row.page_name
                                    ) !==
                                    currentPage
                                );
                            }
                        );

                    if (
                        duplicates.length === 0
                    ) {
                        clearWarning();
                        return;
                    }

                    showWarning(
                        duplicates
                    );
                }
            ).catch(
                function (error) {
                    console.error(
                        '商品重複確認に失敗しました。',
                        error
                    );

                    clearWarning();
                }
            );
        }

        function scheduleCheck() {
            window.clearTimeout(timer);

            timer =
                window.setTimeout(
                    checkDuplicate,
                    300
                );
        }

        stallSelect.addEventListener(
            'change',
            scheduleCheck
        );

        nameInput.addEventListener(
            'input',
            scheduleCheck
        );

        nameInput.addEventListener(
            'change',
            scheduleCheck
        );

        /*
         * 編集画面で既存値が入っている場合にも確認。
         */
        scheduleCheck();

        console.log(
            '商品重複警告を初期化しました。'
        );
    }

    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemDuplicateWarning
        );
    } else {
        setupStallMenuItemDuplicateWarning();
    }

    mw.hook(
        'wikipage.content'
    ).add(
        setupStallMenuItemDuplicateWarning
    );

});

/* =========================================
 * Venue:緯度・経度バリデーション
 * ========================================= */
$(function () {
    const latitudeInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );

    const longitudeInput = document.querySelector(
        'input[name="Venue[longitude]"]'
    );

    function setupVenueCoordinateValidation(
        input,
        label,
        min,
        max
    ) {
        if (!input) {
            return;
        }

        input.inputMode = 'decimal';

        const validateCoordinate = function () {
            const value = input.value.trim();

            input.setCustomValidity('');

            /*
             * Venueでは緯度・経度は任意。
             * 空欄なら正常。
             */
            if (value === '') {
                return;
            }

            /*
             * 数値形式チェック
             */
            if (!/^-?\d+(\.\d+)?$/.test(value)) {
                input.setCustomValidity(
                    label + 'は数値で入力してください。'
                );
                return;
            }

            /*
             * 日本付近の範囲チェック
             */
            const number = Number(value);

            if (number < min || number > max) {
                input.setCustomValidity(
                    label +
                    'は' +
                    min +
                    '〜' +
                    max +
                    'の範囲で入力してください。'
                );
            }
        };

        input.addEventListener(
            'input',
            validateCoordinate
        );

        input.addEventListener(
            'change',
            validateCoordinate
        );

        input.addEventListener(
            'invalid',
            validateCoordinate
        );

        validateCoordinate();
    }

    setupVenueCoordinateValidation(
        latitudeInput,
        '緯度',
        20,
        46
    );

    setupVenueCoordinateValidation(
        longitudeInput,
        '経度',
        122,
        154
    );
});

/* =========================================
 * Venue:地図ピン → 緯度・経度
 * ========================================= */
$(function () {
    const latInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );

    const lonInput = document.querySelector(
        'input[name="Venue[longitude]"]'
    );

    if (!latInput || !lonInput) {
        return;
    }

    mw.loader.using('ext.pageforms.leaflet').then(function () {
        if (
            document.getElementById(
                'matsuri-venue-location-map'
            )
        ) {
            return;
        }

        const mapDiv = document.createElement('div');
        mapDiv.id = 'matsuri-venue-location-map';
        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';

        const help = document.createElement('div');
        help.textContent =
            '地図をクリックして会場位置を指定してください。ピンはドラッグして微調整できます。';
        help.style.marginBottom = '8px';

        const wrapper = document.createElement('div');
        wrapper.appendChild(help);
        wrapper.appendChild(mapDiv);

        const latRow = latInput.closest('tr');

        if (!latRow || !latRow.parentNode) {
            return;
        }

        const mapRow = document.createElement('tr');

        const th = document.createElement('th');
        th.textContent = '会場位置を地図から選択';

        const td = document.createElement('td');
        td.appendChild(wrapper);

        mapRow.appendChild(th);
        mapRow.appendChild(td);

        latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );

        const hasCoordinates =
            latInput.value.trim() !== '' &&
            lonInput.value.trim() !== '' &&
            !Number.isNaN(Number(latInput.value)) &&
            !Number.isNaN(Number(lonInput.value));

        /*
         * 既存座標があればそこを表示。
         * 新規・座標未登録なら日本全体を表示。
         */
        const initialLat = hasCoordinates
            ? Number(latInput.value)
            : 36.2048;

        const initialLon = hasCoordinates
            ? Number(lonInput.value)
            : 138.2529;

        const map = L.map(mapDiv).setView(
            [initialLat, initialLon],
            hasCoordinates ? 17 : 5
        );

        L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);

        let marker = null;

        function updateInputs(lat, lon) {
            const latValue =
                Number(lat).toFixed(6);

            const lonValue =
                Number(lon).toFixed(6);

            latInput.value = latValue;
            lonInput.value = lonValue;

            latInput.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            lonInput.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            latInput.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );

            lonInput.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );
        }

        function placeMarker(latlng) {
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true
                    }
                ).addTo(map);

                marker.on(
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();

                        updateInputs(
                            position.lat,
                            position.lng
                        );
                    }
                );
            }

            updateInputs(
                latlng.lat,
                latlng.lng
            );
        }

        if (hasCoordinates) {
            placeMarker({
                lat: initialLat,
                lng: initialLon
            });
        }

        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
        );

        /*
         * 緯度・経度を手動修正した場合も
         * ピンを同期する。
         */
        function syncMarkerFromInputs() {
            const lat =
                Number(latInput.value);

            const lon =
                Number(lonInput.value);

            if (
                latInput.value.trim() === '' ||
                lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return;
            }

            const latlng = {
                lat: lat,
                lng: lon
            };

            if (marker) {
                marker.setLatLng(latlng);
            } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true
                    }
                ).addTo(map);

                marker.on(
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();

                        updateInputs(
                            position.lat,
                            position.lng
                        );
                    }
                );
            }

            map.setView(
                [lat, lon],
                17
            );
        }

        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        setTimeout(function () {
            map.invalidateSize();
        }, 100);

        console.log(
            'Venue地図ピン入力を初期化しました。'
        );
    });
});

/* =========================================
 * FestivalStallPlacement:
 * 祭り → 会場候補連動
 * ========================================= */
$(function () {
    function setupFestivalVenueFilter() {
        const festivalSelect =
            document.querySelector(
                'select[name="FestivalStallPlacement[festival_id]"]'
            );

        const venueSelect =
            document.querySelector(
                'select[name="FestivalStallPlacement[venue_id]"]'
            );

        if (!festivalSelect || !venueSelect) {
            return;
        }

        if (
            venueSelect.dataset.r5FestivalVenueFilter ===
            '1'
        ) {
            return;
        }

        venueSelect.dataset.r5FestivalVenueFilter =
            '1';

        const api = new mw.Api();

        const originalOptions =
            Array.from(
                venueSelect.options
            ).map(function (option) {
                return option.cloneNode(true);
            });

        const initialFestival =
            festivalSelect.value.trim();

        const initialVenue =
            venueSelect.value.trim();

        let requestId = 0;

        function escapeCargoValue(value) {
            return String(value).replace(
                /'/g,
                "''"
            );
        }

        function getBlankOption(label) {
            let blank =
                originalOptions.find(function (option) {
                    return option.value === '';
                });

            if (blank) {
                blank=blank.cloneNode(true);
            } else {
                blank=document.createElement(
                    'option'
                );
                blank.value='';
            }

            blank.textContent=label;

            return blank;
        }

        function findOriginalOption(value) {
            const option =
                originalOptions.find(
                    function (item) {
                        return item.value === value;
                    }
                );

            return option
                ? option.cloneNode(true)
                : null;
        }

        function replaceOptions(
            venuePages,
            preserveCurrent
        ) {
            const oldValue =
                preserveCurrent
                    ? initialVenue
                    : '';

            const fragment =
                document.createDocumentFragment();

            fragment.appendChild(
                getBlankOption('未指定')
            );

            venuePages.forEach(function (page) {
                let option =
                    findOriginalOption(page);

                if (!option) {
                    console.warn(
                        'Page Formsの元候補に会場がありません。',
                        page
                    );

                    return;
                }

                option.selected=false;
                fragment.appendChild(option);
            });

            if (
                preserveCurrent &&
                oldValue !== '' &&
                !venuePages.includes(oldValue)
            ) {
                const currentOption =
                    findOriginalOption(oldValue);

                if (currentOption) {
                    currentOption.textContent +=
                        '(現在登録値)';

                    fragment.appendChild(
                        currentOption
                    );
                }
            }

            venueSelect.replaceChildren(
                fragment
            );

            let nextValue='';

            if (
                preserveCurrent &&
                oldValue !== '' &&
                Array.from(
                    venueSelect.options
                ).some(function (option) {
                    return option.value ===
                        oldValue;
                })
            ) {
                nextValue=oldValue;
            }

            venueSelect.value=nextValue;
            venueSelect.disabled=false;

            venueSelect.dispatchEvent(
                new Event(
                    'change',
                    { bubbles:true }
                )
            );
        }

        function showLoading() {
            venueSelect.replaceChildren(
                getBlankOption(
                    '会場候補を読み込み中…'
                )
            );

            venueSelect.disabled=true;
        }

        function showFailure(
            preserveCurrent
        ) {
            const fragment =
                document.createDocumentFragment();

            fragment.appendChild(
                getBlankOption(
                    '未指定(候補取得失敗)'
                )
            );

            if (
                preserveCurrent &&
                initialVenue !== ''
            ) {
                const current =
                    findOriginalOption(
                        initialVenue
                    );

                if (current) {
                    current.textContent +=
                        '(現在登録値)';

                    current.selected=true;

                    fragment.appendChild(
                        current
                    );
                }
            }

            venueSelect.replaceChildren(
                fragment
            );

            venueSelect.disabled=false;
        }

        function loadVenues(
            preserveCurrent
        ) {
            const festivalValue =
                festivalSelect.value.trim();

            const currentRequest =
                ++requestId;

            if (festivalValue === '') {
                venueSelect.replaceChildren(
                    getBlankOption('未指定')
                );

                venueSelect.disabled=false;

                return;
            }

            showLoading();

            const escaped =
                escapeCargoValue(
                    festivalValue
                );

            api.get({
                action:'cargoquery',
                format:'json',
                tables:
                    'Festivals=F,' +
                    'FestivalVenues=FV,' +
                    'Venues=V',
                fields:
                    'V._pageName=venue_page,' +
                    'V.name=venue_name,' +
                    'FV.sort_order=sort_order',
                join_on:
                    'F.festival_id=FV.festival_id,' +
                    'FV.venue_id=V.venue_id',
                where:
                    "(" +
                    "F.name='" +
                    escaped +
                    "' OR " +
                    "F._pageName='" +
                    escaped +
                    "'" +
                    ")",
                order_by:
                    'FV.sort_order ASC,' +
                    'V.name ASC',
                limit:'100'
            }).then(function (data) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }

                const rows =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];

                const venuePages=[];

                rows.forEach(function (result) {
                    const row =
                        result.title ||
                        result;

                    const page =
                        row.venue_page ===
                            undefined ||
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();

                    if (
                        page !== '' &&
                        !venuePages.includes(page)
                    ) {
                        venuePages.push(page);
                    }
                });

                replaceOptions(
                    venuePages,
                    preserveCurrent
                );

                console.log(
                    '祭り連動会場候補を更新しました。',
                    {
                        festival:
                            festivalValue,
                        venues:
                            venuePages
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }

                console.error(
                    '祭り連動会場候補の取得に失敗しました。',
                    error
                );

                showFailure(
                    preserveCurrent
                );
            });
        }

        festivalSelect.addEventListener(
            'change',
            function () {
                loadVenues(false);
            }
        );

        loadVenues(
            festivalSelect.value.trim() ===
                initialFestival &&
            initialVenue !== ''
        );
    }

    setupFestivalVenueFilter();

    mw.hook(
        'pf.formSetupAfter'
    ).add(
        setupFestivalVenueFilter
    );
});


/* =========================================
 * FestivalStallPlacement:
 * 会場連動地図ピン → 緯度・経度
 * ========================================= */
$(function () {
    const venueSelect = document.querySelector(
        'select[name="FestivalStallPlacement[venue_id]"]'
    );

    const latInput = document.querySelector(
        'input[name="FestivalStallPlacement[latitude]"]'
    );

    const lonInput = document.querySelector(
        'input[name="FestivalStallPlacement[longitude]"]'
    );

    if (
        !venueSelect ||
        !latInput ||
        !lonInput
    ) {
        return;
    }

    mw.loader.using(
        'ext.pageforms.leaflet'
    ).then(function () {
        if (
            document.getElementById(
                'matsuri-placement-location-map'
            )
        ) {
            return;
        }

        const api = new mw.Api();

        const mapDiv =
            document.createElement('div');

        mapDiv.id =
            'matsuri-placement-location-map';

        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';

        const help =
            document.createElement('div');

        help.textContent =
            '会場を選択すると会場周辺を表示します。' +
            '地図をクリックして実際の屋台位置を指定してください。' +
            'ピンはドラッグして微調整できます。';

        help.style.marginBottom = '8px';

        const wrapper =
            document.createElement('div');

        wrapper.appendChild(help);
        wrapper.appendChild(mapDiv);

        const latRow =
            latInput.closest('tr');

        if (
            !latRow ||
            !latRow.parentNode
        ) {
            return;
        }

        const mapRow =
            document.createElement('tr');

        const th =
            document.createElement('th');

        th.textContent =
            '出店位置を地図から選択';

        const td =
            document.createElement('td');

        td.appendChild(wrapper);

        mapRow.appendChild(th);
        mapRow.appendChild(td);

        latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );

        /*
         * 初期状態は日本全体。
         *
         * 既存Placementに座標がある場合は
         * 後でその位置へ移動する。
         */
        const map = L.map(
            mapDiv
        ).setView(
            [ 36.2048, 138.2529 ],
            5
        );

        L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);

        let marker = null;
        let venueRequestId = 0;

        function dispatchInputEvents(input) {
            input.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            input.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );
        }

        function updateInputs(lat, lon) {
            latInput.value =
                Number(lat).toFixed(6);

            lonInput.value =
                Number(lon).toFixed(6);

            /*
             * 既存の必須・日本範囲チェックを
             * そのまま発火させる。
             */
            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }

        function createMarker(latlng) {
            marker = L.marker(
                latlng,
                {
                    draggable: true
                }
            ).addTo(map);

            marker.on(
                'dragend',
                function () {
                    const position =
                        marker.getLatLng();

                    updateInputs(
                        position.lat,
                        position.lng
                    );
                }
            );
        }

        function placeMarker(latlng) {
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                createMarker(latlng);
            }

            updateInputs(
                latlng.lat,
                latlng.lng
            );
        }

        function removeMarker() {
            if (!marker) {
                return;
            }

            map.removeLayer(marker);
            marker = null;
        }

        function clearCoordinates() {
            latInput.value = '';
            lonInput.value = '';

            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }

        function getCurrentCoordinates() {
            const lat =
                Number(latInput.value);

            const lon =
                Number(lonInput.value);

            if (
                latInput.value.trim() === '' ||
                lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return null;
            }

            return {
                lat: lat,
                lng: lon
            };
        }

        function escapeCargoValue(value) {
            return String(value)
                .replace(
                    /'/g,
                    "''"
                );
        }

        /*
         * 選択されたVenueの座標へ
         * 地図だけ移動する。
         *
         * Placementのlatitude/longitudeには
         * コピーしない。
         */
        function centerOnVenue() {
            const venuePage =
                venueSelect.value.trim();

            if (venuePage === '') {
                return;
            }

            const currentRequest =
                ++venueRequestId;

            api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Venues',
                fields:
                    'venue_id=venue_id,' +
                    '_pageName=page_name,' +
                    'latitude=latitude,' +
                    'longitude=longitude',
                where:
                    "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
            }).then(function (data) {
                /*
                 * 連続して会場を変更した場合、
                 * 古いレスポンスを無視する。
                 */
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }

                const result =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];

                if (result.length === 0) {
                    console.warn(
                        '会場情報を取得できませんでした。',
                        venuePage
                    );
                    return;
                }

                const row =
                    result[0].title ||
                    result[0];

                const lat =
                    Number(row.latitude);

                const lon =
                    Number(row.longitude);

                if (
                    row.latitude === undefined ||
                    row.latitude === null ||
                    String(
                        row.latitude
                    ).trim() === '' ||
                    row.longitude === undefined ||
                    row.longitude === null ||
                    String(
                        row.longitude
                    ).trim() === '' ||
                    Number.isNaN(lat) ||
                    Number.isNaN(lon)
                ) {
                    console.warn(
                        '選択した会場には座標が登録されていません。',
                        venuePage
                    );
                    return;
                }

                map.setView(
                    [ lat, lon ],
                    18
                );

                console.log(
                    '会場位置へ地図を移動しました。',
                    {
                        venue: venuePage,
                        latitude: lat,
                        longitude: lon
                    }
                );
            }).catch(function (error) {
                console.error(
                    '会場座標の取得に失敗しました。',
                    error
                );
            });
        }
        

        /*
         * 地図クリック
         */
        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
        );

        /*
         * 手入力された場合もピンを同期。
         */
        function syncMarkerFromInputs() {
            const coordinates =
                getCurrentCoordinates();

            if (!coordinates) {
                return;
            }

            if (marker) {
                marker.setLatLng(
                    coordinates
                );
            } else {
                createMarker(
                    coordinates
                );
            }

            map.setView(
                [
                    coordinates.lat,
                    coordinates.lng
                ],
                18
            );
        }

        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        /*
         * 会場を変更した場合。
         *
         * 前の会場用の屋台座標を
         * 誤って残さないようクリアする。
         */
        venueSelect.addEventListener(
            'change',
            function () {
                removeMarker();
                clearCoordinates();
                centerOnVenue();
            }
        );

        /*
         * 編集時:
         * 既存Placement座標を優先。
         *
         * 新規時:
         * Venue座標へ地図を移動。
         */
        const initialCoordinates =
            getCurrentCoordinates();

        if (initialCoordinates) {
            createMarker(
                initialCoordinates
            );

            map.setView(
                [
                    initialCoordinates.lat,
                    initialCoordinates.lng
                ],
                18
            );
        } else {
            centerOnVenue();
        }

        setTimeout(
            function () {
                map.invalidateSize();
            },
            100
        );

        console.log(
            '出店位置地図ピン入力を初期化しました。'
        );
    });
});

/* =========================================
 * Venue:公式サイトURLの形式チェック
 * ========================================= */
$(function () {
    const officialSiteInput = document.querySelector(
        'input[name="Venue[official_site]"]'
    );

    if (!officialSiteInput) {
        return;
    }

    officialSiteInput.inputMode = 'url';

    const validateVenueOfficialSite = function () {
        const value = officialSiteInput.value.trim();

        officialSiteInput.setCustomValidity('');

        /*
         * 空欄は許可。
         */
        if (value === '') {
            return;
        }

        try {
            const url = new URL(value);

            /*
             * http:// または https:// のみ許可。
             */
            if (
                url.protocol !== 'http:' &&
                url.protocol !== 'https:'
            ) {
                officialSiteInput.setCustomValidity(
                    '公式サイトURLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            officialSiteInput.setCustomValidity(
                '公式サイトURLを正しいURL形式で入力してください。'
            );
        }
    };

    officialSiteInput.addEventListener(
        'input',
        validateVenueOfficialSite
    );

    officialSiteInput.addEventListener(
        'change',
        validateVenueOfficialSite
    );

    officialSiteInput.addEventListener(
        'invalid',
        validateVenueOfficialSite
    );

    validateVenueOfficialSite();
});

/*
 * Festival
 * 公式URLの形式チェック
 *
 * 空欄は許可。
 * 入力された場合は http:// または https:// のURLのみ許可する。
 */
(function () {
    const fields = [
        'official_site',
        'official_x',
        'official_instagram',
        'official_facebook',
        'official_youtube'
    ];

    fields.forEach(function (fieldName) {
        const input = document.querySelector(
            'input[name="Festival[' + fieldName + ']"]'
        );

        if (!input) {
            return;
        }

        input.inputMode = 'url';

        const validateFestivalUrl = function () {
            const value = input.value.trim();

            input.setCustomValidity('');

            if (value === '') {
                return;
            }

            try {
                const url = new URL(value);

                if (
                    url.protocol !== 'http:' &&
                    url.protocol !== 'https:'
                ) {
                    input.setCustomValidity(
                        'URLは http:// または https:// で始まるURLを入力してください。'
                    );
                }
            } catch (e) {
                input.setCustomValidity(
                    '正しいURL形式で入力してください。'
                );
            }
        };

        input.addEventListener('input', validateFestivalUrl);
        input.addEventListener('change', validateFestivalUrl);
        input.addEventListener('invalid', validateFestivalUrl);
    });
})();

/* =========================================
 * FestivalType:slug形式チェック
 * ========================================= */
$(function () {
    const slugInput = document.querySelector(
        'input[name="FestivalType[slug]"]'
    );

    if (!slugInput) {
        return;
    }

    slugInput.spellcheck = false;

    const validateFestivalTypeSlug = function () {
        const value = slugInput.value.trim();

        slugInput.setCustomValidity('');

        /*
         * 空欄の必須チェックは
         * Page Forms の mandatory に任せる。
         */
        if (value === '') {
            return;
        }

        /*
         * 英小文字・数字を基本とし、
         * 単語の区切りに半角ハイフンのみ許可する。
         *
         * 先頭・末尾のハイフン、
         * 連続ハイフンは許可しない。
         */
        if (
            !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
        ) {
            slugInput.setCustomValidity(
                'slugは英小文字・数字・半角ハイフンで入力してください。ハイフンは単語の区切りにのみ使用できます。'
            );
        }
    };

    slugInput.addEventListener(
        'input',
        validateFestivalTypeSlug
    );

    slugInput.addEventListener(
        'change',
        validateFestivalTypeSlug
    );

    slugInput.addEventListener(
        'invalid',
        validateFestivalTypeSlug
    );

    validateFestivalTypeSlug();
});

/* =========================================
 * FestivalType:
 * 上位分類の自己参照・循環参照チェック
 *
 * Page Forms が select 要素を差し替えても
 * 動作するようイベント委譲を使用する。
 * ========================================= */
window.matsuriFestivalTypeParentValidationVersion = '20260821-v2';
(function () {
    const currentTypeId = Number(
        mw.config.get('wgArticleId')
    );

    let requestId = 0;

    function escapeCargoValue(value) {
        return String(value).replace(
            /'/g,
            "''"
        );
    }

        function getTypeByName(name) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();

            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'FestivalTypes',
                fields:
                    'type_id=type_id,' +
                    'name=name,' +
                    'parent_id=parent_id',
                where:
                    "name='" +
                    escapeCargoValue(name) +
                    "'",
                limit: '2'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];

            return rows.map(function (item) {
                return item.title || item;
            });
        });
    }

    function getTypeById(typeId) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();

            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'FestivalTypes',
                fields:
                    'type_id=type_id,' +
                    'name=name,' +
                    'parent_id=parent_id',
                where:
                    'type_id=' +
                    Number(typeId),
                limit: '1'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];

            if (rows.length === 0) {
                return null;
            }

            return rows[0].title || rows[0];
        });
    }

    function validateFestivalTypeParent(
        parentSelect
    ) {
        const thisRequest = ++requestId;

        parentSelect.setCustomValidity('');

        const parentName =
            parentSelect.value.trim();

        const nameInput = document.querySelector(
            'input[name="FestivalType[name]"]'
        );

        const currentName =
            nameInput
                ? nameInput.value.trim()
                : '';

        /*
         * 親なしは正常。
         */
        if (parentName === '') {
            return;
        }

        /*
         * 分類名が同じならAPIを待たず
         * 即座に自己参照として拒否する。
         */
        if (
            currentName !== '' &&
            parentName === currentName
        ) {
            parentSelect.setCustomValidity(
                '自分自身を上位分類に設定することはできません。'
            );
            return;
        }

        /*
         * 新規ページはまだtype_idを持たないため、
         * 既存階層との循環は発生しない。
         */
        if (
            !Number.isInteger(currentTypeId) ||
            currentTypeId <= 0
        ) {
            return;
        }

        /*
         * API確認中はフォーム送信を止める。
         */
        parentSelect.setCustomValidity(
            '上位分類を確認しています。'
        );

        /*
         * 非同期処理中に別の選択へ変更された、
         * またはPage Formsがselectを差し替えたか確認する。
         */
        function isStaleRequest() {
            return (
                thisRequest !== requestId ||
                !document.contains(parentSelect)
            );
        }

        /*
         * 選択した分類から親を順番に辿る。
         *
         * async / await は使用せず、
         * Promise の再帰処理で階層を確認する。
         */
        function walkParentChain(
            parentId,
            visited
        ) {
            if (
                parentId === undefined ||
                parentId === null ||
                String(parentId).trim() === ''
            ) {
                return Promise.resolve(true);
            }

            const numericParentId =
                Number(parentId);

            /*
             * 現在編集中の分類へ戻れば循環。
             */
            if (
                numericParentId ===
                currentTypeId
            ) {
                parentSelect.setCustomValidity(
                    'この上位分類を設定すると分類階層が循環するため選択できません。'
                );

                return Promise.resolve(false);
            }

            /*
             * 既存データ側ですでに循環している場合。
             */
            if (
                visited.has(
                    numericParentId
                )
            ) {
                parentSelect.setCustomValidity(
                    '選択した上位分類の階層に循環があります。'
                );

                return Promise.resolve(false);
            }

            visited.add(
                numericParentId
            );

            return getTypeById(
                numericParentId
            ).then(function (row) {
                if (isStaleRequest()) {
                    return false;
                }

                if (!row) {
                    parentSelect.setCustomValidity(
                        '上位分類の階層情報を確認できませんでした。'
                    );

                    return false;
                }

                return walkParentChain(
                    row.parent_id,
                    visited
                );
            });
        }

        return getTypeByName(
            parentName
        ).then(function (parentRows) {
            /*
             * その間に別の選択へ変更された場合は
             * 古い結果を無視する。
             */
            if (isStaleRequest()) {
                return false;
            }

            if (parentRows.length === 0) {
                parentSelect.setCustomValidity(
                    '選択した上位分類を確認できませんでした。'
                );

                return false;
            }

            /*
             * 同名分類が複数ある場合は
             * parent_id を一意に決められない。
             */
            if (parentRows.length > 1) {
                parentSelect.setCustomValidity(
                    '同じ名前の祭り分類が複数存在するため、上位分類を特定できません。'
                );

                return false;
            }

            const selectedParent =
                parentRows[0];

            const selectedTypeId =
                Number(
                    selectedParent.type_id
                );

            /*
             * IDでも自己参照をチェックする。
             * 分類名を編集中に変更した場合にも有効。
             */
            if (
                selectedTypeId ===
                currentTypeId
            ) {
                parentSelect.setCustomValidity(
                    '自分自身を上位分類に設定することはできません。'
                );

                return false;
            }

            const visited =
                new Set([
                    selectedTypeId
                ]);

            return walkParentChain(
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                /*
                 * すべて正常。
                 */
                parentSelect.setCustomValidity('');
            }

            return isValid;
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }

            parentSelect.setCustomValidity(
                '上位分類を確認できませんでした。'
            );

            console.error(
                '祭り分類の上位分類チェックに失敗しました。',
                error
            );

            return false;
        });
    }

    /*
     * Page Forms が select を差し替えても
     * document 側で変更を拾う。
     */
document.addEventListener(
    'change',
    function (event) {
        const target =
            event.target;

        if (
            target &&
            target.matches(
                'select[name="FestivalType[parent_id]"]'
            )
        ) {
            validateFestivalTypeParent(
                target
            );
        }
    },
    true
);

    /*
     * 初期表示時にも現在存在するselectを確認。
     */
    function validateCurrentParent() {
        const parentSelect =
            document.querySelector(
                'select[name="FestivalType[parent_id]"]'
            );

        if (parentSelect) {
            validateFestivalTypeParent(
                parentSelect
            );
        }
    }

    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            validateCurrentParent
        );
    } else {
        validateCurrentParent();
    }

    /*
     * Page Formsによる描画後にも再確認する。
     */
    if (
        typeof mw !== 'undefined' &&
        mw.hook
    ) {
        mw.hook(
            'wikipage.content'
        ).add(
            validateCurrentParent
        );
    }
})();

/* =========================================
 * Area:
 * Area ID・上位地域・地域区分の整合性チェック
 *
 * 既存Areaデータの正式な階層ルール:
 *
 * prefecture
 *   parent_id = 0
 *
 * city / special_ward / town / village
 *   parent = prefecture
 *
 * ward
 *   parent = city
 *
 * 自己参照・存在しない親・循環参照も拒否する。
 *
 * MediaWiki 1.43対応のため
 * async / await は使用しない。
 * ========================================= */

window.matsuriAreaParentValidationVersion = '20260821-v1';

(function () {
    let requestId = 0;

    function getAreaById(areaId) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();

            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Areas',
                fields:
                    'area_id=area_id,' +
                    'name=name,' +
                    'area_type=area_type,' +
                    'parent_id=parent_id',
                where:
                    'area_id=' +
                    Number(areaId),
                limit: '2'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];

            return rows.map(function (item) {
                return item.title || item;
            });
        });
    }

    function getExpectedParentType(areaType) {
        switch (areaType) {
            case 'prefecture':
                return '';

            case 'city':
            case 'special_ward':
            case 'town':
            case 'village':
                return 'prefecture';

            case 'ward':
                return 'city';

            default:
                return null;
        }
    }

    function getParentTypeMessage(areaType) {
        switch (areaType) {
            case 'city':
                return '市の上位地域には都道府県を指定してください。';

            case 'special_ward':
                return '特別区の上位地域には都道府県を指定してください。';

            case 'town':
                return '町の上位地域には都道府県を指定してください。';

            case 'village':
                return '村の上位地域には都道府県を指定してください。';

            case 'ward':
                return '行政区の上位地域には市を指定してください。';

            default:
                return '地域区分と上位地域の組み合わせが正しくありません。';
        }
    }

    function validateAreaParent() {
        const areaIdInput =
            document.querySelector(
                'input[name="Area[area_id]"]'
            );

        const areaTypeSelect =
            document.querySelector(
                'select[name="Area[area_type]"]'
            );

        const parentIdInput =
            document.querySelector(
                'input[name="Area[parent_id]"]'
            );

        /*
         * Areaフォーム以外では何もしない。
         */
        if (
            !areaIdInput ||
            !areaTypeSelect ||
            !parentIdInput
        ) {
            return;
        }

        const thisRequest = ++requestId;

        areaIdInput.setCustomValidity('');
        parentIdInput.setCustomValidity('');

        const areaIdText =
            areaIdInput.value.trim();

        const parentIdText =
            parentIdInput.value.trim();

        const areaType =
            areaTypeSelect.value;

        /*
         * Area ID は正の整数。
         */
        if (
            !/^[1-9][0-9]*$/.test(
                areaIdText
            )
        ) {
            areaIdInput.setCustomValidity(
                'Area IDは1以上の整数で入力してください。'
            );

            return;
        }

        const areaId =
            Number(areaIdText);

        /*
         * parent_id は0以上の整数。
         */
        if (
            !/^(0|[1-9][0-9]*)$/.test(
                parentIdText
            )
        ) {
            parentIdInput.setCustomValidity(
                '上位地域IDは0以上の整数で入力してください。'
            );

            return;
        }

        const parentId =
            Number(parentIdText);

        const expectedParentType =
            getExpectedParentType(
                areaType
            );

        /*
         * 想定外のarea_type。
         */
        if (
            expectedParentType === null
        ) {
            areaTypeSelect.setCustomValidity(
                '地域区分を正しく選択してください。'
            );

            return;
        }

        areaTypeSelect.setCustomValidity('');

        /*
         * 都道府県は必ずROOT。
         */
        if (
            areaType === 'prefecture'
        ) {
            if (parentId !== 0) {
                parentIdInput.setCustomValidity(
                    '都道府県の上位地域IDは0にしてください。'
                );

                return;
            }

            /*
             * 都道府県 parent_id=0 は正常。
             */
            return;
        }

        /*
         * 都道府県以外は必ず親を持つ。
         */
        if (parentId === 0) {
            parentIdInput.setCustomValidity(
                getParentTypeMessage(
                    areaType
                )
            );

            return;
        }

        /*
         * 自己参照。
         */
        if (parentId === areaId) {
            parentIdInput.setCustomValidity(
                '自分自身を上位地域に設定することはできません。'
            );

            return;
        }

        /*
         * API確認中は保存を止める。
         */
        parentIdInput.setCustomValidity(
            '上位地域を確認しています。'
        );

        function isStaleRequest() {
            return (
                thisRequest !== requestId ||
                !document.contains(
                    parentIdInput
                )
            );
        }

        /*
         * 親を順番に辿って循環参照を確認。
         */
        function walkParentChain(
            nextParentId,
            visited
        ) {
            if (
                nextParentId === undefined ||
                nextParentId === null ||
                String(nextParentId).trim() === '' ||
                Number(nextParentId) === 0
            ) {
                return Promise.resolve(true);
            }

            const numericParentId =
                Number(nextParentId);

            /*
             * 現在編集中のAreaへ戻れば循環。
             */
            if (
                numericParentId === areaId
            ) {
                parentIdInput.setCustomValidity(
                    'この上位地域を設定すると地域階層が循環するため指定できません。'
                );

                return Promise.resolve(false);
            }

            /*
             * 既存データ側ですでに循環している場合。
             */
            if (
                visited.has(
                    numericParentId
                )
            ) {
                parentIdInput.setCustomValidity(
                    '選択した上位地域の階層に循環があります。'
                );

                return Promise.resolve(false);
            }

            visited.add(
                numericParentId
            );

            return getAreaById(
                numericParentId
            ).then(function (rows) {
                if (isStaleRequest()) {
                    return false;
                }

                if (rows.length !== 1) {
                    parentIdInput.setCustomValidity(
                        '上位地域の階層情報を確認できませんでした。'
                    );

                    return false;
                }

                return walkParentChain(
                    rows[0].parent_id,
                    visited
                );
            });
        }

        return getAreaById(
            parentId
        ).then(function (parentRows) {
            if (isStaleRequest()) {
                return false;
            }

            /*
             * 存在しないparent_id。
             */
            if (parentRows.length === 0) {
                parentIdInput.setCustomValidity(
                    '指定した上位地域IDは存在しません。'
                );

                return false;
            }

            /*
             * area_id重複がDB側に存在する異常状態。
             */
            if (parentRows.length > 1) {
                parentIdInput.setCustomValidity(
                    '同じArea IDの地域が複数存在するため、上位地域を特定できません。'
                );

                return false;
            }

            const selectedParent =
                parentRows[0];

            /*
             * 地域区分と親地域区分の整合性。
             */
            if (
                selectedParent.area_type !==
                expectedParentType
            ) {
                parentIdInput.setCustomValidity(
                    getParentTypeMessage(
                        areaType
                    )
                );

                return false;
            }

            const visited =
                new Set([
                    parentId
                ]);

            return walkParentChain(
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                parentIdInput.setCustomValidity('');
            }

            return isValid;
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }

            parentIdInput.setCustomValidity(
                '上位地域を確認できませんでした。'
            );

            console.error(
                'Areaの上位地域チェックに失敗しました。',
                error
            );

            return false;
        });
    }

    /*
     * Page Forms上で値が変更された場合。
     */
    document.addEventListener(
        'change',
        function (event) {
            const target =
                event.target;

            if (
                target &&
                (
                    target.matches(
                        'input[name="Area[area_id]"]'
                    ) ||
                    target.matches(
                        'select[name="Area[area_type]"]'
                    ) ||
                    target.matches(
                        'input[name="Area[parent_id]"]'
                    )
                )
            ) {
                validateAreaParent();
            }
        },
        true
    );

    /*
     * 初期表示時の検証。
     */
    function validateCurrentArea() {
        if (
            document.querySelector(
                'input[name="Area[area_id]"]'
            )
        ) {
            validateAreaParent();
        }
    }

    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            validateCurrentArea
        );
    } else {
        validateCurrentArea();
    }
}());

/* ========================================
 * FestivalCalendar 開催年4桁チェック
 * ======================================== */

$( function () {

    const yearInput = document.querySelector(
        'input[name="FestivalCalendar[year]"]'
    );

    if ( !yearInput ) {
        return;
    }

    /*
     * 二重初期化防止
     */
    if (
        yearInput.dataset
            .festivalCalendarYearValidation === '1'
    ) {
        return;
    }

    yearInput.dataset
        .festivalCalendarYearValidation = '1';

    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;

    const validateYear = function () {

        const value =
            yearInput.value.trim();

        /*
         * 空欄については
         * Page Forms の mandatory に任せる。
         */
        if (
            value !== '' &&
            !/^\d{4}$/.test( value )
        ) {

            yearInput.setCustomValidity(
                '開催年は4桁の数字で入力してください(例:2027)'
            );

        } else {

            yearInput.setCustomValidity( '' );

        }

    };

    yearInput.addEventListener(
        'input',
        validateYear
    );

    yearInput.addEventListener(
        'change',
        validateYear
    );

    /*
     * 編集画面を開いた時点の値も検査
     */
    validateYear();

    window
        .matsuriFestivalCalendarYearValidationVersion =
        '20260821-v1';

} );

/* ========================================
 * FestivalCalendar 開催日の前後関係チェック
 * ======================================== */

$( function () {

    const startInput = document.querySelector(
        'input[name="FestivalCalendar[start_date]"]'
    );

    const endInput = document.querySelector(
        'input[name="FestivalCalendar[end_date]"]'
    );

    if ( !startInput || !endInput ) {
        return;
    }

    if (
        endInput.dataset
            .festivalCalendarDateValidation === '1'
    ) {
        return;
    }

    endInput.dataset
        .festivalCalendarDateValidation = '1';

    const validateDates = function () {

        const startDate =
            startInput.value.trim();

        const endDate =
            endInput.value.trim();

        /*
         * 終了日は任意。
         * 両方入力されている場合だけ前後関係を確認する。
         *
         * type=date の値は YYYY-MM-DD なので
         * 文字列比較で日付順を判定できる。
         */
        if (
            startDate !== '' &&
            endDate !== '' &&
            endDate < startDate
        ) {

            endInput.setCustomValidity(
                '終了日は開始日以降の日付を入力してください。'
            );

        } else {

            endInput.setCustomValidity( '' );

        }

    };

    startInput.addEventListener(
        'input',
        validateDates
    );

    startInput.addEventListener(
        'change',
        validateDates
    );

    endInput.addEventListener(
        'input',
        validateDates
    );

    endInput.addEventListener(
        'change',
        validateDates
    );

    validateDates();

    window
        .matsuriFestivalCalendarDateValidationVersion =
        '20260821-v1';

} );

/* ========================================
 * FestivalCalendar 予想来場者数チェック
 * ======================================== */

$( function () {

    const visitorsInput = document.querySelector(
        'input[name="FestivalCalendar[expected_visitors]"]'
    );

    if ( !visitorsInput ) {
        return;
    }

    /*
     * 二重初期化防止
     */
    if (
        visitorsInput.dataset
            .festivalCalendarVisitorsValidation === '1'
    ) {
        return;
    }

    visitorsInput.dataset
        .festivalCalendarVisitorsValidation = '1';

    visitorsInput.inputMode = 'numeric';

    const validateVisitors = function () {

        const value =
            visitorsInput.value.trim();

        /*
         * 空欄は許可。
         * 入力する場合は0以上の整数のみ。
         */
        if (
            value !== '' &&
            !/^\d+$/.test( value )
        ) {

            visitorsInput.setCustomValidity(
                '予想来場者数は0以上の整数で入力してください。'
            );

        } else {

            visitorsInput.setCustomValidity( '' );

        }

    };

    visitorsInput.addEventListener(
        'input',
        validateVisitors
    );

    visitorsInput.addEventListener(
        'change',
        validateVisitors
    );

    validateVisitors();

    window
        .matsuriFestivalCalendarVisitorsValidationVersion =
        '20260821-v1';

} );