注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。

  • Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
  • Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
  • Microsoft Edge: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください。
/* ========================================
 * 屋台比較
 * 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'
                    );

                }
            );


    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;
    }


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


    const expectedIds =
        new Set(
            placementIds.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;
            }


            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 'おおよその位置';

            case 'test':
                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,' +
                    'serving_note=serving_note,' +
                    '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,' +
                        'sort_order=sort_order',

                        '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 ||
                    '',


                servingNote:
                    offering
                        .serving_note ||
                    '',


                /*
                 * 表示用単位価格
                 */
                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: '未確認',
        test: 'テストデータ'
    };

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

    if (!select) {
        return;
    }

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

$(function () {
    const statusLabels = {
        active: '出店中・出店予定',
        cancelled: '出店中止',
        unknown: '未確認',
        test: 'テストデータ'
    };

    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: '位置未確認',
    test: 'テスト位置'
};

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;
        }

        /*
         * 数値形式チェック
         */
        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,
        '緯度',
        -90,
        90
    );

const validateLongitude =
    setupCoordinateValidation(
        longitudeInput,
        '経度',
        -180,
        180
    );

/*
 * 位置情報の状態を変更した場合、
 * 緯度・経度を再検証する。
 */
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 visibleInput = getVisibleInput();

			if (visibleInput) {
				['input', 'change'].forEach(function (eventName) {
					visibleInput.addEventListener(eventName, function () {
						window.setTimeout(function () {
							if (dateInput.validity.valid) {
								clearError();
							} else if (dateInput.validity.rangeOverflow) {
								showError();
							}
						}, 0);
					});
				});
			}
		});
	}

	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) {
	warning.replaceChildren();

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

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

	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 =
		'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
		rows.length +
		'件あります。';

	warning.appendChild(title);

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

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

	description.textContent =
		'出店場所が異なる場合は新規登録して構いません。' +
		'下の既存データと同じ場所ではないか確認してください。';

	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 ||
		!venueValue ||
		!/^\d{4}$/.test(yearValue)
	) {
		clearWarning();
		return;
	}

	/*
	 * async / await は使わず、
	 * Promise の then() で処理する。
	 */
	Promise.all([
		resolveId(
			'Stalls',
			'stall_id',
			'name',
			stallValue
		),
		resolveId(
			'Festivals',
			'festival_id',
			'name',
			festivalValue
		),
		resolveId(
			'Venues',
			'venue_id',
			'name',
			venueValue
		)
	])
	.then(function (ids) {
		if (currentRequest !== requestId) {
			return null;
		}

		if (!ids[0] || !ids[1] || !ids[2]) {
			clearWarning();
			return null;
		}

		const where =
			'festival_id=' +
			ids[1] +
			' AND year=' +
			yearValue +
			' AND venue_id=' +
			ids[2] +
			' AND stall_id=' +
			ids[0];

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;
			}

			/*
			 * 今回は警告のみ。
			 * 同条件の既存データをすべて表示する。
			 */
			showCandidates(rows);
		});
	})
	.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);
            }
        );

        /*
         * 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,' +
                'sort_order=sort_order',
            where:
                'stall_id=' +
                Number(stallId) +
                " AND status='active'",
            order_by:
                'sort_order,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 ||
            !stall.value
        ) {
            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(
        '屋台→販売商品連動を初期化しました。'
    );

});

/*
 * 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
    );

});