R9 rollback: restore known-good Common.js after ResourceLoader parse error
タグ: 手動差し戻し
安全な画像アップロードでファイル名の警告理由を表示
 
(3人の利用者による、間の21版が非表示)
1,969行目: 1,969行目:
let festivalMapMarkerIndexReady =
let festivalMapMarkerIndexReady =
     false;
     false;
/*
* R10-5C ISSUE-07:
* Festival地図の初期viewportを
* marker群へ合わせたか。
*/
let festivalMapInitialViewportApplied =
    false;
/*
* R10-5C7:
* Maps拡張の初期center/zoom処理が
* 完了した次taskでviewportを適用する。
*/
let festivalMapInitialViewportTimer =
    null;
let festivalMapInitialViewportAttempts =
    0;
const MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS =
    40;




2,097行目: 2,119行目:
         festivalMapMarkerIndexReady
         festivalMapMarkerIndexReady
     ) {
     ) {
        scheduleFestivalMapInitialViewport();
         return true;
         return true;
     }
     }
2,243行目: 2,267行目:
             }
             }
         );
         );
    if (
        festivalMapMarkerIndexReady
    ) {
        scheduleFestivalMapInitialViewport();
    }




2,252行目: 2,283行目:




/* =====================================
/*
  * marker表示状態を変更
* =====================================
  * ===================================== */
  * R10-5C ISSUE-07
* Festival「地図から探す」初期viewport
*
* 0 marker:
*  現行fallbackを維持
*
* 1 marker:
*  marker中央、zoom上限17
*
* 2 marker以上:
*  全markerをfitBounds
*  padding 32px
*  maxZoom 17
  * =====================================
*/
function scheduleFestivalMapInitialViewport() {


function applyMapMarkerFilter(
    if (
     visiblePlacementIds
        festivalMapInitialViewportApplied ||
) {
        !festivalMapMarkerIndexReady
     ) {
        return false;
    }


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


    /*
    * 二重timer防止。
    */
    if (
        festivalMapInitialViewportTimer !==
            null
    ) {
        return true;
    }


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


            const item =
    /*
                 festivalMapMarkerIndex[
    * Festival地図に属するmarkerから
    * 対象Leaflet mapを特定する。
    */
    const indexedItem =
        mapPlacementIds
            .map(
                String
            )
            .map(
                 function (
                     placementId
                     placementId
                 ];
                 ) {
                    return (
                        festivalMapMarkerIndex[
                            placementId
                        ] ||
                        null
                    );
                }
            )
            .find(
                function ( item ) {
                    return Boolean(
                        item &&
                        item.marker &&
                        item.markerLayer &&
                        item.markerLayer._map
                    );
                }
            );




            if (
    /*
                !item ||
    * 座標付きplacement自体が0件なら
                !item.marker ||
    * Maps側のfallbackを正式採用して完了。
                 !item.markerLayer
    *
            ) {
    * placementが存在するのにindexedItemが
                 return;
    * まだ取れない場合は初期化途中なので、
            }
    * applied=trueにせず再試行する。
    */
    if (
        !indexedItem
    ) {
 
        if (
            mapPlacementIds.length ===
                 0
        ) {
            festivalMapInitialViewportApplied =
                 true;


            return true;
        }


            const marker =
                item.marker;


            const markerLayer =
        festivalMapInitialViewportAttempts +=
                item.markerLayer;
            1;




             /*
        if (
            * markerが現在表示されているか
             festivalMapInitialViewportAttempts >=
            */
            MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
             const isShown =
        ) {
                 typeof markerLayer
             console.warn(
                    .hasLayer ===
                 '祭り屋台地図:markerのLeaflet map接続を確認できなかったため、初期viewport調整を中止しました。'
                    'function'
            );
                    ? markerLayer.hasLayer(
                        marker
                    )
                    : true;


            return false;
        }


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


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


                     markerLayer.addLayer(
                     festivalMapInitialViewportTimer =
                         marker
                         null;
                    );


                 }
                    scheduleFestivalMapInitialViewport();
                 },
                100
            );




            /*
        return true;
            * 非表示対象
    }
            */
            } else {


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


                    markerLayer.removeLayer(
    const targetMap =
                        marker
        indexedItem
                    );
            .markerLayer
            ._map;


                }


             }
    const mapsEntry =
        Array.isArray(
             window.mapsLeafletList
        )
            ? window.mapsLeafletList
                .find(
                    function ( entry ) {
                        return Boolean(
                            entry &&
                            entry.map ===
                                targetMap
                        );
                    }
                )
            : null;


        }
    );


}
    /*
    * Maps setup() は doSetup() 冒頭で
    * ranSetup=true にした後、
    * centerAndZoomMap() を実行する。
    *
    * ranSetup=trueでも同一call stack中なら
    * Maps側のzoom=18がまだ後続するため、
    * 必ず次taskへ送る。
    *
    * ranSetup前なら100ms単位で待機する。
    */
    const delay =
        mapsEntry &&
        mapsEntry.ranSetup ===
            true
            ? 0
            : 100;




/* =====================================
    festivalMapInitialViewportTimer =
* 地図初期化待ち
        window.setTimeout(
* ===================================== */
            function () {


function scheduleMapMarkerIndex() {
                festivalMapInitialViewportTimer =
                    null;


    if (
        festivalMapMarkerIndexReady
    ) {


        applyMapMarkerFilter(
                /*
            pendingVisiblePlacementIds
                * timer実行時点でもMaps setupが
        );
                * 完了していなければ再試行。
                */
                if (
                    !mapsEntry ||
                    mapsEntry.ranSetup !==
                        true
                ) {


        return;
                    festivalMapInitialViewportAttempts +=
    }
                        1;




    /*
                    if (
    * 二重タイマー防止
                        festivalMapInitialViewportAttempts >=
    */
                        MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
    if (
                    ) {
        mapIndexTimer !== null
                        console.warn(
    ) {
                            '祭り屋台地図:Maps初期化完了を確認できなかったため、初期viewport調整を中止しました。'
        return;
                        );
    }


                        return;
                    }


    function tryIndex() {


        mapIndexTimer =
                    scheduleFestivalMapInitialViewport();
            null;


                    return;
                }


        if (
            buildFestivalMapMarkerIndex()
        ) {


            /*
                if (
            * 地図準備完了後、
                    applyFestivalMapInitialViewport()
            * 最新の絞り込み状態を反映
                ) {
            */
                    festivalMapInitialViewportAttempts =
            applyMapMarkerFilter(
                        0;
                pendingVisiblePlacementIds
 
            );
                    return;
                }


            return;
        }


                festivalMapInitialViewportAttempts +=
                    1;


        mapIndexAttempts +=
            1;


                if (
                    festivalMapInitialViewportAttempts >=
                    MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
                ) {
                    console.warn(
                        '祭り屋台地図:初期viewportを適用できなかったため、再試行を中止しました。'
                    );


        if (
                    return;
            mapIndexAttempts >=
                }
            MAX_MAP_INDEX_ATTEMPTS
        ) {


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


             return;
                scheduleFestivalMapInitialViewport();
         }
            },
             delay
         );




        mapIndexTimer =
    return true;
            window.setTimeout(
}
                tryIndex,
                100
            );


    }


function applyFestivalMapInitialViewport() {


     tryIndex();
     if (
        festivalMapInitialViewportApplied ||
        !festivalMapMarkerIndexReady
    ) {
        return false;
    }


}


    if (
        typeof L === 'undefined'
    ) {
        return false;
    }


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


function syncMapMarkers(
     const items =
     visiblePlacementIds
         mapPlacementIds
) {
    pendingVisiblePlacementIds =
         visiblePlacementIds
             .map(
             .map(
                 String
                 String
            )
            .map(
                function (
                    placementId
                ) {
                    return (
                        festivalMapMarkerIndex[
                            placementId
                        ] ||
                        null
                    );
                }
            )
            .filter(
                function ( item ) {
                    return Boolean(
                        item &&
                        item.marker &&
                        typeof item.marker
                            .getLatLng ===
                            'function' &&
                        item.markerLayer
                    );
                }
             );
             );




    /*
    * mapPlacementIdsが存在する状態で
    * itemsが0件なのは初期化途中。
    *
    * applied=trueにはせず、
    * schedulerへfalseを返して再試行させる。
    */
     if (
     if (
         buildFestivalMapMarkerIndex()
         items.length === 0
     ) {
     ) {
        return false;
    }
    const map =
        items[
            0
        ].markerLayer &&
        items[
            0
        ].markerLayer._map
            ? items[
                0
            ].markerLayer._map
            : null;


        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );


         return;
    if (
        !map ||
        typeof map.setView !==
            'function' ||
        typeof map.fitBounds !==
            'function'
    ) {
         return false;
     }
     }




     /*
     /*
     * Maps側がまだ初期化されていれば待つ
     * 同一Festival地図に属するmarkerだけを
    * viewport計算へ使用。
     */
     */
     scheduleMapMarkerIndex();
     const latLngs =
 
        items
}
            .filter(
                function ( item ) {
/* =====================================
                    return (
* placement_idのmarkerを開く
                        item.markerLayer &&
* ===================================== */
                        item.markerLayer._map ===
 
                            map
function openPlacementOnMap(
                    );
    placementId
                }
) {
            )
 
            .map(
    const id =
                function ( item ) {
        String(
                    return item.marker
            placementId ||
                        .getLatLng();
             ''
                }
        );
            )
            .filter(
                function ( latlng ) {
                    return Boolean(
                        latlng &&
                        Number.isFinite(
                            Number(
                                latlng.lat
                            )
                        ) &&
                        Number.isFinite(
                            Number(
                                latlng.lng
                            )
                        )
                    );
                }
             );




     if (
     if (
         !/^\d+$/.test(
         latLngs.length === 0
            id
        ) ||
        id === '0'
     ) {
     ) {
         return false;
         return false;
2,498行目: 2,661行目:




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


         buildFestivalMapMarkerIndex();
         map.setView(
            latLngs[
                0
            ],
            17,
            {
                animate:
                    false
            }
        );
 
    } else {


    }
        map.fitBounds(
            L.latLngBounds(
                latLngs
            ),
            {
                padding:
                    [
                        32,
                        32
                    ],


                maxZoom:
                    17,


    const item =
                animate:
        festivalMapMarkerIndex[
                    false
             id
             }
         ];
         );


    }


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


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


        return false;
    return true;
}


    }


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


     const marker =
function applyMapMarkerFilter(
        item.marker;
     visiblePlacementIds
) {


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


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


         item.markerLayer.addLayer(
    Object.keys(
            marker
         festivalMapMarkerIndex
         );
    ).forEach(
         function ( placementId ) {


    }
            const item =
                festivalMapMarkerIndex[
                    placementId
                ];




    /*
            if (
    * 地図までスクロール
                !item ||
    */
                !item.marker ||
    if (
                !item.markerLayer
        item.mapElement &&
        typeof item.mapElement
            .scrollIntoView ===
            'function'
    ) {
 
        item.mapElement.scrollIntoView(
            {
                behavior:
                    'smooth',
 
                block:
                    'center'
            }
        );
 
    }
 
 
    /*
    * 少し待ってpopupを開く
    */
    window.setTimeout(
        function () {
 
            if (
                typeof marker.openPopup ===
                'function'
             ) {
             ) {
 
                 return;
                 marker.openPopup();
 
             }
             }
        },
        300
    );




    return true;
            const marker =
                item.marker;


}
            const markerLayer =
                item.markerLayer;


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


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


    cards.forEach(
        function ( card ) {


             /*
             /*
             * 二重生成防止
             * 表示対象
             */
             */
             if (
             if (
                 card.querySelector(
                 visibleIds.has(
                     '.festival-stall-map-view'
                     placementId
                 )
                 )
             ) {
             ) {
                return;
            }


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


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


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




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


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


            const wrapper =
                    markerLayer.removeLayer(
                document.createElement(
                        marker
                     '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 =
function scheduleMapMarkerIndex() {
                '地図で見る';


    if (
        festivalMapMarkerIndexReady
    ) {


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


        return;
    }


            wrapper.appendChild(
                button
            );


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


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


    function tryIndex() {


            if (
        mapIndexTimer =
                compareControl &&
             null;
                compareControl.parentNode
             ) {


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


             } else {
        if (
             buildFestivalMapMarkerIndex()
        ) {


                /*
            /*
                * 比較ボタンが見つからない場合は
            * 地図準備完了後、
                * カード末尾
            * 最新の絞り込み状態を反映
                */
            */
                card.appendChild(
            applyMapMarkerFilter(
                    wrapper
                pendingVisiblePlacementIds
                );
            );
 
            }


            return;
         }
         }
    );


}


/* =====================================
        mapIndexAttempts +=
* 「地図で見る」クリック
            1;
* ===================================== */
 


document.addEventListener(
        if (
    'click',
            mapIndexAttempts >=
    function ( event ) {
            MAX_MAP_INDEX_ATTEMPTS
        ) {


        const button =
             console.warn(
             event.target.closest(
                 '祭り屋台地図:placement_idとmarkerを対応付けできませんでした。'
                 '.festival-stall-map-view-button'
             );
             );


        if (
            !button
        ) {
             return;
             return;
         }
         }




         const placementId =
         mapIndexTimer =
             String(
             window.setTimeout(
                 button.dataset
                 tryIndex,
                    .placementId ||
                 100
                 ''
             );
             );


    }


        const opened =
            openPlacementOnMap(
                placementId
            );


    tryIndex();


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


            scheduleMapMarkerIndex();


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


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


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


    if (
        buildFestivalMapMarkerIndex()
    ) {


             window.setTimeout(
        applyMapMarkerFilter(
                function () {
             pendingVisiblePlacementIds
        );


                    button.disabled =
        return;
                        false;
    }


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


    /*
    * Maps側がまだ初期化されていれば待つ
    */
    scheduleMapMarkerIndex();
}
/* =====================================
* placement_idのmarkerを開く
* ===================================== */


                    openPlacementOnMap(
function openPlacementOnMap(
                        placementId
    placementId
                    );
) {


                },
    const id =
                500
        String(
             );
             placementId ||
            ''
        );


        }


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




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


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


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




     const label =
     const item =
         document.createElement(
         festivalMapMarkerIndex[
             'label'
             id
         );
         ];


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


     label.textContent =
     if (
        '屋台を検索';
        !item ||
        !item.marker
    ) {


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


    input.type =
         return false;
         'search';


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


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


     input.setAttribute(
     const marker =
         'autocomplete',
         item.marker;
        'off'
    );


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


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


function createFilterSelect(
         item.markerLayer.addLayer(
    labelText,
             marker
    className,
    allText
) {
 
    const wrapper =
         document.createElement(
             'label'
         );
         );


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




     const title =
     /*
         document.createElement(
    * 地図までスクロール
            'span'
    */
         );
    if (
 
         item.mapElement &&
    title.className =
         typeof item.mapElement
        'festival-stall-filter-label';
            .scrollIntoView ===
            'function'
    ) {


    title.textContent =
        item.mapElement.scrollIntoView(
        labelText;
            {
                behavior:
                    'smooth',


 
                block:
    const select =
                    'center'
        document.createElement(
             }
             'select'
         );
         );


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




     const allOption =
     /*
        document.createElement(
    * 少し待ってpopupを開く
            'option'
    */
         );
    window.setTimeout(
         function () {


    allOption.value =
            if (
        '';
                typeof marker.openPopup ===
                'function'
            ) {


    allOption.textContent =
                marker.openPopup();
        allText;


            }


    select.appendChild(
        },
         allOption
         300
     );
     );




     wrapper.appendChild(
     return true;
        title
    );


    wrapper.appendChild(
}
        select
    );


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


    return {
function createMapViewButtons() {
        wrapper:
            wrapper,


        select:
     cards.forEach(
            select
        function ( card ) {
     };
 
}


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


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


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


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


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


const categorySelect =
    categoryFilter.select;


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


const venueSelect =
    venueFilter.select;


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


/*
            wrapper.className =
* フィルター行
                'festival-stall-map-view';
*/
const filterRow =
    document.createElement(
        'div'
    );


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


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


filterRow.appendChild(
            button.type =
    categoryFilter.wrapper
                'button';
);


filterRow.appendChild(
            button.className =
    venueFilter.wrapper
                'festival-stall-map-view-button';
);


/*
            button.dataset.placementId =
* 絞り込みリセット
                placementId;
* ===================================== */


const resetButton =
            button.textContent =
    document.createElement(
                '地図で見る';
        'button'
    );


resetButton.type =
    'button';


resetButton.className =
            button.setAttribute(
    'festival-stall-search-reset';
                'aria-label',
                'この屋台を地図で見る'
            );


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


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


resetButton.disabled =
    true;


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


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


/* =====================================
            if (
* 検索結果0件メッセージ
                compareControl &&
* ===================================== */
                compareControl.parentNode
            ) {


const noResults =
                compareControl.parentNode
    document.createElement(
                    .insertBefore(
        'div'
                        wrapper,
    );
                        compareControl
                            .nextSibling
                    );


noResults.className =
            } else {
    'festival-stall-search-empty';


noResults.textContent =
                /*
    '条件に一致する屋台はありません。検索条件を変更してください。';
                * 比較ボタンが見つからない場合は
                * カード末尾
                */
                card.appendChild(
                    wrapper
                );


noResults.hidden =
            }
    true;


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


    label.appendChild(
}
        input
    );


searchBox.appendChild(
/* =====================================
    label
* 「地図で見る」クリック
);
* ===================================== */


searchBox.appendChild(
document.addEventListener(
     filterRow
     'click',
);
    function ( event ) {


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


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


        if (
            !button
        ) {
            return;
        }


searchBox.appendChild(
    count
);


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




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




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


    searchAnchor.appendChild(
            scheduleMapMarkerIndex();
        searchBox
    );


} else {


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


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


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


const searchIndex = {};
            window.setTimeout(
                function () {


                    button.disabled =
                        false;


/*
                    button.textContent =
* select候補
                        '地図で見る';
*/
const categoryOptions =
    new Map();


const venueOptions =
    new Map();


                    openPlacementOnMap(
                        placementId
                    );


cards.forEach(
                },
    function ( card ) {
                500
            );


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


    }
);


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


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


        const venueName =
    const searchBox =
            String(
        document.createElement(
                card.dataset
            'div'
                    .venueName ||
        );
                ''
            ).trim();


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


        const normalizedCategory =
            normalizeSearchText(
                category
            );


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


        const normalizedVenue =
    label.className =
            normalizeSearchText(
        'festival-stall-search-label';
                venueName
            );


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


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


            text:
    const input =
                normalizeSearchText(
        document.createElement(
                    card.textContent
            'input'
                ),
        );


            category:
    input.type =
                normalizedCategory,
        'search';


            venueName:
    input.className =
                normalizedVenue
        'festival-stall-search-input';


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


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


        /*
    input.setAttribute(
        * カテゴリselect候補
        'aria-label',
        */
        '屋台名または商品名で検索'
        if (
    );
            normalizedCategory &&
            !categoryOptions.has(
                normalizedCategory
            )
        ) {


            categoryOptions.set(
/* =====================================
                normalizedCategory,
* フィルターselect
                category
* ===================================== */
            );


        }
function createFilterSelect(
    labelText,
    className,
    allText
) {


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


        /*
    wrapper.className =
        * 会場select候補
         'festival-stall-filter';
        */
        if (
            normalizedVenue &&
            !venueOptions.has(
                normalizedVenue
            )
         ) {


            venueOptions.set(
                normalizedVenue,
                venueName
            );


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


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


/* =====================================
    title.textContent =
* select option生成
        labelText;
* ===================================== */


function fillFilterOptions(
    select,
    optionMap
) {


     const options =
     const select =
         Array.from(
         document.createElement(
             optionMap.entries()
             'select'
         );
         );


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


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


            return a[
    const allOption =
                1
        document.createElement(
            ].localeCompare(
            'option'
                b[
        );
                    1
                ],
                'ja'
            );


         }
    allOption.value =
    );
         '';


    allOption.textContent =
        allText;


    options.forEach(
        function ( optionData ) {


            const value =
    select.appendChild(
                optionData[
        allOption
                    0
    );
                ];


            const label =
                optionData[
                    1
                ];


    wrapper.appendChild(
        title
    );


            const option =
    wrapper.appendChild(
                document.createElement(
        select
                    'option'
    );
                );


            option.value =
                value;


             option.textContent =
    return {
                label;
        wrapper:
             wrapper,


 
        select:
             select.appendChild(
             select
                option
    };
            );
 
        }
    );


}
}




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




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


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


    function updateCount(
const categorySelect =
        visible
     categoryFilter.select;
     ) {


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


     }
const venueSelect =
     venueFilter.select;




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


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


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


function applySearch() {
filterRow.appendChild(
    categoryFilter.wrapper
);


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


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


     /*
const resetButton =
    * カテゴリ
     document.createElement(
    */
        'button'
     const selectedCategory =
     );
        categorySelect.value;


resetButton.type =
    'button';


    /*
resetButton.className =
    * 会場
     'festival-stall-search-reset';
    */
     const selectedVenue =
        venueSelect.value;


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


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


resetButton.disabled =
    true;


/*
    const count =
* 地図に残すplacement_id
        document.createElement(
*/
            'div'
const visiblePlacementIds =
        );
    [];


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


cards.forEach(
/* =====================================
        function ( card ) {
* 検索結果0件メッセージ
* ===================================== */


            const placementId =
const noResults =
                String(
    document.createElement(
                    card.dataset
        'div'
                        .placementId ||
    );
                    ''
                );


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


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


                    text:
noResults.hidden =
                        '',
    true;


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


                    venueName:
    label.appendChild(
                        ''
        input
    );


                };
searchBox.appendChild(
    label
);


searchBox.appendChild(
    filterRow
);


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


            const keywordMatched =
/*
                !keyword ||
* リセット
                index.text.includes(
*/
                    keyword
searchBox.appendChild(
                );
    resetButton
);




            /* =============================
searchBox.appendChild(
            * カテゴリ
    count
            * ============================= */
);


            const categoryMatched =
searchBox.appendChild(
                !selectedCategory ||
    noResults
                index.category ===
);
                    selectedCategory;




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


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


if (
    searchAnchor
) {


            /* =============================
     searchAnchor.appendChild(
            * AND条件
         searchBox
            * ============================= */
     );
 
            const matched =
                keywordMatched &&
                categoryMatched &&
                venueMatched;
 
 
if (
     matched
) {
 
    card.style.display =
         '';
 
     visible +=
        1;


} else {


     /*
     /*
     * 地図にも残す
     * 古いテンプレート等への
    * フォールバック
     */
     */
     visiblePlacementIds.push(
     cards[
         placementId
        0
    ].parentNode.insertBefore(
         searchBox,
        cards[
            0
        ]
     );
     );


} else {
}


                card.style.display =
    /* =====================================
                    'none';
    * カードごとの検索文字列
 
    *
            }
    * 最初はカード本文だけ
 
    * ===================================== */
        }
    );


 
const searchIndex = {};
updateCount(
    visible
);




/*
/*
  * 0件メッセージ
  * select候補
  */
  */
noResults.hidden =
const categoryOptions =
     visible !== 0;
     new Map();


const venueOptions =
    new Map();


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


cards.forEach(
    function ( card ) {


/*
        const placementId =
* 地図を一覧と同期
            String(
*/
                card.dataset
syncMapMarkers(
                    .placementId ||
    visiblePlacementIds
                ''
);
            );




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




/*
        const venueName =
* 各カードへ
            String(
* 地図で見るボタン
                card.dataset
*/
                    .venueName ||
createMapViewButtons();
                ''
 
            ).trim();
/*
* 初期状態
*
* 最初は全placementを表示
*/
syncMapMarkers(
    placementIds
);




    input.addEventListener(
        const normalizedCategory =
        'input',
            normalizeSearchText(
        applySearch
                category
    );
            );


categorySelect.addEventListener(
    'change',
    applySearch
);


        const normalizedVenue =
            normalizeSearchText(
                venueName
            );


venueSelect.addEventListener(
    'change',
    applySearch
);
/* =====================================
* 絞り込みをすべてリセット
* ===================================== */
resetButton.addEventListener(
    'click',
    function () {


         /*
         /*
         * フリーワード
         * placementごとの検索情報
         */
         */
         input.value =
         searchIndex[
             '';
            placementId
        ] = {
 
            text:
                normalizeSearchText(
                    card.textContent
                ),
 
            category:
                normalizedCategory,
 
             venueName:
                normalizedVenue
 
        };




         /*
         /*
         * カテゴリ
         * カテゴリselect候補
         */
         */
         categorySelect.value =
         if (
             '';
            normalizedCategory &&
            !categoryOptions.has(
                normalizedCategory
             )
        ) {


            categoryOptions.set(
                normalizedCategory,
                category
            );


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




         /*
         /*
         * 一覧・件数・0件表示・
         * 会場select候補
        * 地図markerをすべて再計算
         */
         */
         applySearch();
         if (
            normalizedVenue &&
            !venueOptions.has(
                normalizedVenue
            )
        ) {


            venueOptions.set(
                normalizedVenue,
                venueName
            );


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


     }
     }
);
);


    /* =====================================
/* =====================================
    * Placement → Offering取得
* select option生成
    * ===================================== */
* ===================================== */


     cargoQuery(
function fillFilterOptions(
     select,
    optionMap
) {


         'FestivalStallMenuOfferings',
    const options =
         Array.from(
            optionMap.entries()
        );


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


        'placement_id IN (' +
    /*
         placementIds.join(
    * 表示名で並び替え
            ','
    */
        ) +
    options.sort(
        ')'
         function ( a, b ) {


    ).then(
            return a[
        function ( offerings ) {
                1
            ].localeCompare(
                b[
                    1
                ],
                'ja'
            );


        }
    );


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


                                    return String(
    options.forEach(
                                        offering
        function ( optionData ) {
                                            .menu_item_id ||
                                        ''
                                    );


                                }
            const value =
                            )
                optionData[
                            .filter(
                    0
                                function ( id ) {
                ];


                                    return /^\d+$/.test(
            const label =
                                        id
                optionData[
                                    );
                     1
 
                                }
                            )
                     )
                 ];
                 ];




             /*
             const option =
            * メニューが1件も無い
                document.createElement(
            */
                    'option'
            if (
                 );
                 menuItemIds.length === 0
            ) {


                 return {
            option.value =
                    offerings:
                 value;
                        offerings,


                    menus:
            option.textContent =
                        []
                 label;
                 };


            }


            select.appendChild(
                option
            );


            /* =================================
        }
            * MenuItem名取得
    );
            * ================================= */


            return cargoQuery(
}


                'StallMenuItems',


                'menu_item_id=menu_item_id,' +
fillFilterOptions(
                'name=menu_name',
    categorySelect,
    categoryOptions
);


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


            ).then(
fillFilterOptions(
                function ( menus ) {
    venueSelect,
    venueOptions
);


                    return {
    /* =====================================
    * 件数表示
    * ===================================== */


                        offerings:
    function updateCount(
                            offerings,
        visible
    ) {


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


                    };
    }


                }
            );


        }
     updateCount(
     ).then(
         cards.length
         function ( data ) {
    );


            if (
                !data
            ) {
                return;
            }


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


            /* =================================
function applySearch() {
            * menu_item_id → 商品名
            * ================================= */


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




            data.menus.forEach(
    /*
                function ( menu ) {
    * カテゴリ
    */
    const selectedCategory =
        categorySelect.value;


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


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




            /* =================================
let visible =
            * placement_id → 商品名[]
    0;
            * ================================= */


            const placementMenus =
                {};


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


            data.offerings.forEach(
                function ( offering ) {


                    const placementId =
cards.forEach(
                        String(
        function ( card ) {
                            offering
                                .placement_id ||
                            ''
                        );


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




                    const menuName =
            const index =
                        menuNameMap[
                searchIndex[
                            menuItemId
                    placementId
                        ] || '';
                ] || {


                    text:
                        '',


                     if (
                     category:
                         !menuName
                         '',
                    ) {
                        return;
                    }


                    venueName:
                        ''


                    if (
                };
                        !placementMenus[
                            placementId
                        ]
                    ) {


                        placementMenus[
                            placementId
                        ] = [];


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


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


                    placementMenus[
                        placementId
                    ].push(
                        menuName
                    );


                }
            /* =============================
            );
            * カテゴリ
            * ============================= */


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


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


             cards.forEach(
             /* =============================
                function ( card ) {
            * 会場
            * ============================= */


                    const placementId =
            const venueMatched =
                        String(
                !selectedVenue ||
                            card.dataset
                index.venueName ===
                                .placementId ||
                    selectedVenue;
                            ''
                        );




                    const menuNames =
            /* =============================
                        placementMenus[
            * AND条件
                            placementId
            * ============================= */
                        ] || [];
 
            const matched =
                keywordMatched &&
                categoryMatched &&
                venueMatched;




if (
if (
     searchIndex[
     matched
        placementId
    ]
) {
) {


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


}
    visible +=
        1;


                }
            );


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


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


        }
                card.style.display =
    ).catch(
                    'none';
        function ( error ) {


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


         }
         }
     );
     );


} );


/* ========================================
updateCount(
* 屋台比較ページ
    visible
* placement_id 正式版
);
* ======================================== */


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


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




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




    /*
/*
    * 屋台比較ページ以外では終了
* 地図を一覧と同期
    */
*/
     if ( !compareRoot ) {
syncMapMarkers(
        return;
     visiblePlacementIds
    }
);




    const STORAGE_KEY =
}
        'matsuriWikiComparePlacements';


    const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;


    const api =
/*
        new mw.Api();
* 各カードへ
* 地図で見るボタン
*/
createMapViewButtons();


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


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


     function getPlacementIds() {
     input.addEventListener(
        'input',
        applySearch
    );


        const raw =
categorySelect.addEventListener(
            mw.storage.get(
    'change',
                STORAGE_KEY
    applySearch
            );
);




        if ( !raw ) {
venueSelect.addEventListener(
            return [];
    'change',
        }
    applySearch
);


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


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


            const ids =
        /*
                JSON.parse(
        * フリーワード
                    raw
        */
                );
        input.value =
            '';




            if (
        /*
                !Array.isArray(
        * カテゴリ
                    ids
        */
                )
        categorySelect.value =
             ) {
             '';
                return [];
            }




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




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


            return [];


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


     }
     }
 
);


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


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


         const params = {
         'FestivalStallMenuOfferings',


            action: 'cargoquery',
        'placement_id=placement_id,' +
        'menu_item_id=menu_item_id',


             tables: table,
        'placement_id IN (' +
        placementIds.join(
             ','
        ) +
        ')'


            fields: fields,
    ).then(
        function ( offerings ) {


            limit: limit || 100,


             format: 'json'
             const menuItemIds =
                [
                    ...new Set(
                        offerings
                            .map(
                                function (
                                    offering
                                ) {


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


                                }
                            )
                            .filter(
                                function ( id ) {


        if ( where ) {
                                    return /^\d+$/.test(
            params.where = where;
                                        id
        }
                                    );


 
                                }
        return api.get(
                            )
            params
        ).then(
            function ( data ) {
 
                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                     )
                     )
                 ) {
                 ];
                    return [];
                }




                 return data.cargoquery.map(
            /*
                    function ( item ) {
            * メニューが1件も無い
            */
            if (
                 menuItemIds.length === 0
            ) {


                        return (
                return {
                            item.title ||
                    offerings:
                            item
                         offerings,
                         );


                     }
                     menus:
                 );
                        []
                 };


             }
             }
        );


    }


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


    function makeInClause( ids ) {
            return cargoQuery(


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


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


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


    function uniqueIds( values ) {
            ).then(
                function ( menus ) {


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


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


                         }
                         menus:
                    )
                            menus
            )
        ];


    }
                    };


                }
            );


     function mapBy(
        }
         rows,
     ).then(
        key
         function ( data ) {
    ) {


        const result = {};
            if (
                !data
            ) {
                return;
            }




        rows.forEach(
            /* =================================
            function ( row ) {
            * menu_item_id → 商品名
            * ================================= */


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




                 result[
            data.menus.forEach(
                    String(
                 function ( menu ) {
                        row[ key ]
                    )
                ] = row;


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


                }
            );


        return result;


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


            const placementMenus =
                {};


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


    function textOrDash(
            data.offerings.forEach(
        value
                function ( offering ) {
    ) {


        if (
                    const placementId =
            value === undefined ||
                        String(
            value === null ||
                            offering
            value === ''
                                .placement_id ||
        ) {
                            ''
            return '―';
                        );
        }


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


    }


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


function cleanNumber(
    value
) {


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


    const number =
        Number(
            value
        );


    if (
                    if (
        !Number.isFinite(
                        !placementMenus[
            number
                            placementId
        )
                        ]
    ) {
                    ) {
        return '';
    }


    if (
                        placementMenus[
        Number.isInteger(
                            placementId
            number
                        ] = [];
        )
    ) {


        return String(
                    }
            number
        );


    }


    return String(
                    placementMenus[
        Math.round(
                        placementId
            number * 100
                    ].push(
        ) / 100
                        menuName
    );
                    );
 
                }
            );
 


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


            cards.forEach(
                function ( card ) {


/* =====================================
                    const placementId =
* 比較計算用数値
                        String(
* ===================================== */
                            card.dataset
                                .placementId ||
                            ''
                        );


function toFiniteNumber(
    value
) {


    if (
                    const menuNames =
        value === undefined ||
                        placementMenus[
        value === null ||
                            placementId
        String( value ).trim() === ''
                        ] || [];
    ) {
        return null;
    }


    const number =
        Number(
            value
        );


    if (
if (
        !Number.isFinite(
    searchIndex[
            number
         placementId
         )
     ]
     ) {
) {
        return null;
    }


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


}
}


    function formatHours(
                }
        placement
            );
    ) {


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


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


         const open =
         }
            placement.opening_time || '';
    ).catch(
        function ( error ) {


        const close =
             /*
             placement.closing_time || '';
            * 商品データ取得に失敗しても
 
            * 屋台名検索は使えるようにする
 
            */
        if (
             console.error(
             open &&
                 '屋台商品検索データ取得エラー:',
            close
                 error
        ) {
 
            return (
                open +
                 '' +
                 close
             );
             );


         }
         }
    );


} );


        if ( open ) {
/* ========================================
* 屋台比較ページ
* placement_id 正式版
* ======================================== */


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


        }
    'use strict';




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


            return (
                '~' +
                close
            );


         }
    /*
    * 屋台比較ページ以外では終了
    */
    if ( !compareRoot ) {
         return;
    }
 
 
    const STORAGE_KEY =
        'matsuriWikiComparePlacements';
 
    const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;
 
    const api =
        new mw.Api();




         if (
    /* =====================================
             placement.hours_note
    * localStorage
        ) {
    * ===================================== */
 
    function getPlacementIds() {
 
         const raw =
             mw.storage.get(
                STORAGE_KEY
            );


            return placement.hours_note;


        if ( !raw ) {
            return [];
         }
         }




         return '未確認';
         try {
 
            const ids =
                JSON.parse(
                    raw
                );


    }


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


    function formatPositionStatus(
        status
    ) {


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


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


case 'approximate':
        } catch ( e ) {
    return 'おおよその位置';


default:
            return [];
    return '位置未確認';


         }
         }
4,278行目: 4,410行目:




     function formatVerification(
    /* =====================================
         status
    * Cargo
    * ===================================== */
 
     function cargoQuery(
         table,
        fields,
        where,
        limit
     ) {
     ) {


         switch ( status ) {
         const params = {
 
            action: 'cargoquery',
 
            tables: table,
 
            fields: fields,


             case 'verified':
             limit: limit || 100,
                return '確認済み';


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


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


            default:
                return '未確認';


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


    }


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


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


        switch ( status ) {


            case 'available':
                 return data.cargoquery.map(
                 return '販売あり';
                    function ( item ) {


            case 'unavailable':
                        return (
                return '販売なし';
                            item.title ||
                            item
                        );


            default:
                    }
                 return '未確認';
                 );


         }
            }
         );


     }
     }




/* =====================================
    function makeInClause( ids ) {
* 単位価格
* 表示用
* ===================================== */


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


    const price =
        toFiniteNumber(
            offering.price
        );


     const quantity =
     function uniqueIds( values ) {
        toFiniteNumber(
            offering.serving_quantity
        );


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


    if (
                            return (
        price === null ||
                                id &&
        quantity === null ||
                                /^\d+$/.test(
        quantity <= 0
                                    id
    ) {
                                )
                            );


         return '―';
                        }
                    )
            )
         ];


     }
     }




     const unitPrice =
     function mapBy(
         Math.round(
         rows,
            (
        key
                price /
    ) {
                quantity
            ) *
            100
        ) /
        100;


        const result = {};


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


        rows.forEach(
            function ( row ) {


    return (
                if (
        unitPrice +
                    row[ key ] ===
        '円/' +
                    undefined
        unit
                ) {
    );
                    return;
                }


}


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


/* =====================================
             }
* 単位価格
* 比較計算用
* ===================================== */
 
function getUnitPriceValue(
    offering
) {
 
    const price =
        toFiniteNumber(
             offering.price
         );
         );


    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


 
         return result;
    if (
        price === null ||
        quantity === null ||
        quantity <= 0
    ) {
 
         return null;


     }
     }
    return (
        price /
        quantity
    );
}




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


     function createTextCell(
     function textOrDash(
         tagName,
         value
        text
     ) {
     ) {


         const cell =
         if (
             document.createElement(
            value === undefined ||
                tagName
             value === null ||
             );
            value === ''
        ) {
             return '―';
        }


         cell.textContent =
         return String(
             text;
             value
 
         );
         return cell;


     }
     }




     /* =====================================
function cleanNumber(
    * メニュー
     value
    * ===================================== */
) {


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


        const container =
    const number =
            document.createElement(
        Number(
                'div'
            value
            );
        );


         container.className =
    if (
             'stall-compare-menu-list';
         !Number.isFinite(
             number
        )
    ) {
        return '';
    }


    if (
        Number.isInteger(
            number
        )
    ) {


         if (
         return String(
             !menus ||
             number
            menus.length === 0
         );
         ) {


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


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


        }
}




        menus.forEach(
/* =====================================
            function ( item ) {
* 比較計算用数値
* ===================================== */


                const menu =
function toFiniteNumber(
                    document.createElement(
    value
                        'div'
) {
                    );


                menu.className =
    if (
                    'stall-compare-menu-item';
        value === undefined ||
        value === null ||
        String( value ).trim() === ''
    ) {
        return null;
    }
 
    const number =
        Number(
            value
        );


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


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


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


                name.textContent =
    function formatHours(
                    item.menuName ||
        placement
                    '商品';
    ) {


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


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


price.className =
        const open =
    'stall-compare-menu-price';
            placement.opening_time || '';


        const close =
            placement.closing_time || '';


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


        if (
            open &&
            close
        ) {


/*
            return (
* 最安価格
                open +
*/
                '~' +
if (
                close
    item.isLowestPrice
            );
) {


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


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


    badge.textContent =
         if ( open ) {
         '最安価格';


            return (
                open +
                '~'
            );


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


    price.appendChild(
        badge
    );


}
        if ( close ) {


            return (
                '~' +
                close
            );


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




                if (
        if (
                    item.servingQuantity
            placement.hours_note
                ) {
        ) {


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


                } else {
        }


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


                }
        return '未確認';


    }


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


unit.className =
    function formatPositionStatus(
     'stall-compare-menu-unit-price';
        status
     ) {


        switch ( status ) {


unit.textContent =
case 'exact':
     '1単位あたり:' +
     return '正確な位置';
    item.unitPrice;


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


/*
default:
* 最安単位価格
     return '位置未確認';
*/
if (
     item.isLowestUnitPrice
) {


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


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


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


    function formatVerification(
        status
    ) {


    unit.appendChild(
         switch ( status ) {
         document.createTextNode(
            ' '
        )
    );


    unit.appendChild(
            case 'verified':
        badge
                return '確認済み';
    );


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


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


                 const availability =
            default:
                    document.createElement(
                 return '未確認';
                        'div'
                    );


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


    }


                menu.appendChild(
                    name
                );


                menu.appendChild(
    function formatAvailability(
                    price
        status
                );
    ) {


                menu.appendChild(
        switch ( status ) {
                    serving
                );


                menu.appendChild(
            case 'available':
                    unit
                 return '販売あり';
                 );


                menu.appendChild(
            case 'unavailable':
                    availability
                 return '販売なし';
                 );


            default:
                return '未確認';


                container.appendChild(
        }
                    menu
 
                );
    }


            }
        );


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


        return container;
function getUnitPrice(
    offering
) {


     }
     const price =
        toFiniteNumber(
            offering.price
        );


    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


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


     function renderComparison(
     if (
         compareData
         price === null ||
        quantity === null ||
        quantity <= 0
     ) {
     ) {


         compareRoot.innerHTML =
         return '';
            '';


    }


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


         heading.textContent =
    const unitPrice =
             '屋台比較';
         Math.round(
            (
                price /
                quantity
            ) *
             100
        ) /
        100;




         compareRoot.appendChild(
    const unit =
            heading
         offering.serving_unit ||
         );
         '単位';




         const wrapper =
    return (
            document.createElement(
         unitPrice +
                'div'
        '円/' +
            );
        unit
    );


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




        const table =
/* =====================================
            document.createElement(
* 単位価格
                'table'
* 比較計算用
            );
* ===================================== */


        table.className =
function getUnitPriceValue(
            'stall-compare-table';
    offering
) {


    const price =
        toFiniteNumber(
            offering.price
        );


         /* ------------------------------
    const quantity =
        * thead
         toFiniteNumber(
        * ------------------------------ */
            offering.serving_quantity
        );


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


         const headerRow =
    if (
            document.createElement(
         price === null ||
                'tr'
        quantity === null ||
            );
        quantity <= 0
    ) {


        return null;


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




         compareData.forEach(
    return (
            function ( data ) {
        price /
         quantity
    );


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




                if (
    /* =====================================
                    data.stall &&
    * DOM
                    data.stall.page_name
    * ===================================== */
                ) {


                    const link =
    function createTextCell(
                        document.createElement(
        tagName,
                            'a'
        text
                        );
    ) {


                    link.href =
        const cell =
                        mw.util.getUrl(
            document.createElement(
                            data.stall
                tagName
                                .page_name
            );
                        );


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


        return cell;


                    th.appendChild(
    }
                        link
                    );


                } else {


                    th.textContent =
    /* =====================================
                        data.stall
    * メニュー
                            ? data.stall.stall_name
    * ===================================== */
                            : '屋台';


                }
    function createMenuList(
        menus
    ) {


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


                headerRow.appendChild(
        container.className =
                    th
            'stall-compare-menu-list';
                );


            }
        );


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


        thead.appendChild(
            container.textContent =
            headerRow
                'メニュー未登録';
        );


        table.appendChild(
             return container;
             thead
        );


        }


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


        menus.forEach(
            function ( item ) {


        function addRow(
                const menu =
            label,
                    document.createElement(
            getter
                        'div'
        ) {
                    );


            const tr =
                 menu.className =
                 document.createElement(
                     'stall-compare-menu-item';
                     'tr'
                );




            const labelCell =
                const name =
                createTextCell(
                    document.createElement(
                    'th',
                        'strong'
                     label
                     );
                );


            labelCell.scope =
                name.className =
                'row';
                    'stall-compare-menu-name';


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


            tr.appendChild(
                labelCell
            );


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


            compareData.forEach(
price.className =
                function ( data ) {
    'stall-compare-menu-price';


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


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




            tbody.appendChild(
/*
                tr
* 最安価格
            );
*/
if (
    item.isLowestPrice
) {


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


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


        /* =================================
    badge.textContent =
        * Placement情報
         '最安価格';
        * ================================= */
 
         addRow(
            '開催年',
            function ( data ) {


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


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


    price.appendChild(
        badge
    );


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


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


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




        addRow(
                if (
            '会場',
                    item.servingQuantity
            function ( data ) {
                ) {


                return data.venue
                    serving.textContent =
                    ? data.venue
                        '内容量:' +
                         .venue_name
                        item.servingQuantity +
                    : '';
                         (
                            item.servingUnit ||
                            ''
                        );


            }
                } else {
        );


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


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


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


            }
const unit =
         );
    document.createElement(
         'div'
    );
 
unit.className =
    'stall-compare-menu-unit-price';
 


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


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


                return data.stall
/*
                    ? data.stall
* 最安単位価格
                        .category
*/
                    : '―';
if (
    item.isLowestUnitPrice
) {


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


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


         addRow(
    badge.textContent =
            '出店場所',
         '最安単位価格';
            function ( data ) {


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


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


    unit.appendChild(
        badge
    );


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


                return formatHours(
                    data.placement
                );


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


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


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


                 return data.placement
                 menu.appendChild(
                     ? formatPositionStatus(
                     name
                        data.placement
                );
                            .position_status
 
                     )
                menu.appendChild(
                     : '―';
                     price
                );
 
                menu.appendChild(
                     serving
                );


            }
                menu.appendChild(
        );
                    unit
                );


                menu.appendChild(
                    availability
                );


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


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


             }
             }
4,969行目: 5,109行目:




         /* =================================
         return container;
        * メニュー
        * ================================= */


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




        const menuLabel =
    /* =====================================
            createTextCell(
    * 比較表
                'th',
    * ===================================== */
                'メニュー'
            );


         menuLabel.scope =
    function renderComparison(
            'row';
         compareData
    ) {


        compareRoot.innerHTML =
            '';


        menuRow.appendChild(
            menuLabel
        );


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


         compareData.forEach(
         heading.textContent =
             function ( data ) {
             '屋台比較';


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


        compareRoot.appendChild(
            heading
        );


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


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


                menuRow.appendChild(
        wrapper.className =
                    td
            'stall-compare-table-wrapper';
                );


            }
        );


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


         tbody.appendChild(
         table.className =
             menuRow
             'stall-compare-table';
        );




         table.appendChild(
         /* ------------------------------
            tbody
        * thead
        );
        * ------------------------------ */


         wrapper.appendChild(
         const thead =
             table
            document.createElement(
        );
                'thead'
             );


        compareRoot.appendChild(
         const headerRow =
            wrapper
        );
 
 
         const note =
             document.createElement(
             document.createElement(
                 'p'
                 'tr'
             );
             );


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


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


    }


        compareData.forEach(
            function ( data ) {


    function renderMessage(
                const th =
        message
                    document.createElement(
    ) {
                        'th'
                    );


        compareRoot.innerHTML =
            '';


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


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


        p.className =
                    link.href =
            'stall-compare-page-message';
                        mw.util.getUrl(
                            data.stall
                                .page_name
                        );


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




        compareRoot.appendChild(
                    th.appendChild(
            p
                        link
        );
                    );


    }
                } else {


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


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


    const placementIds =
        getPlacementIds();


                headerRow.appendChild(
                    th
                );


    if (
             }
        placementIds.length <
        MIN_COMPARE
    ) {
 
        renderMessage(
             '比較する出店を2件以上選択してください。'
         );
         );


        return;


    }
        thead.appendChild(
            headerRow
        );


        table.appendChild(
            thead
        );


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


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


        '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 (' +
         function addRow(
        makeInClause(
            label,
             placementIds
             getter
        ) +
         ) {
         ')',


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


    ).then(
        function ( placements ) {


             const stallIds =
             const labelCell =
                 uniqueIds(
                 createTextCell(
                     placements.map(
                     'th',
                        function ( row ) {
                     label
                            return row.stall_id;
                        }
                     )
                 );
                 );


            labelCell.scope =
                'row';


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




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


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


            /*
                }
            * STEP 2
             );
            */
             return Promise.all( [


                stallIds.length
                    ? cargoQuery(


                        'Stalls',
            tbody.appendChild(
                tr
            );


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


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


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


                    )
        addRow(
                    : Promise.resolve(
            '開催年',
                        []
            function ( data ) {
                    ),


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


                festivalIds.length
            }
                    ? cargoQuery(
        );


                        'Festivals',


                        'festival_id=festival_id,' +
        addRow(
                        'name=festival_name,' +
            '祭り',
                        '_pageName=page_name',
            function ( data ) {


                        'festival_id IN (' +
                return data.festival
                        makeInClause(
                    ? data.festival
                            festivalIds
                         .festival_name
                         ) +
                    : '';
                        ')',


                        100
            }
        );


                    )
                    : Promise.resolve(
                        []
                    ),


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


                 venueIds.length
                 return data.venue
                     ? cargoQuery(
                     ? data.venue
                        .venue_name
                    : '―';


                        'Venues',
            }
        );


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


                        'venue_id IN (' +
        addRow(
                        makeInClause(
            '地域',
                            venueIds
            function ( data ) {
                        ) +
                        ')',


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


                    )
            }
                    : Promise.resolve(
        );
                        []
                    ),




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


                     'FestivalStallMenuOfferings',
                return data.stall
                     ? data.stall
                        .category
                    : '';


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


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


                     100
        addRow(
            '出店場所',
            function ( data ) {
 
                return data.placement
                     ? data.placement
                        .location_note
                    : '―';


                )
            }
        );


            ] ).then(
                function ( results ) {


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


                        placements:
                return formatHours(
                            placements,
                    data.placement
                );


                        stalls:
            }
                            results[ 0 ],
        );


                        festivals:
                            results[ 1 ],


                        venues:
        addRow(
                            results[ 2 ],
            '位置情報',
            function ( data ) {


                         offerings:
                return data.placement
                             results[ 3 ]
                    ? formatPositionStatus(
                         data.placement
                             .position_status
                    )
                    : '―';


                    };
            }
        );


                }
            );


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


            const areaIds =
                 return data.placement
                 uniqueIds(
                     ? formatVerification(
                     data.venues.map(
                         data.placement
                         function ( row ) {
                             .verification_status
                             return row.area_id;
                        }
                     )
                     )
                );
                    : '―';
 
            }
        );




            const menuItemIds =
        /* =================================
                uniqueIds(
        * メニュー
                    data.offerings.map(
        * ================================= */
                        function ( row ) {
 
                            return row.menu_item_id;
        const menuRow =
                        }
            document.createElement(
                    )
                'tr'
                 );
            );
 
 
        const menuLabel =
            createTextCell(
                'th',
                 'メニュー'
            );


        menuLabel.scope =
            'row';


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


                areaIds.length
        menuRow.appendChild(
                    ? cargoQuery(
            menuLabel
        );


                        'Areas',


                        'area_id=area_id,' +
        compareData.forEach(
                        'name=area_name,' +
            function ( data ) {
                        '_pageName=page_name',


                        'area_id IN (' +
                const td =
                        makeInClause(
                    document.createElement(
                            areaIds
                         'td'
                         ) +
                    );
                        ')',


                        100


                td.appendChild(
                    createMenuList(
                        data.menus
                     )
                     )
                    : Promise.resolve(
                );
                        []
                    ),




                 menuItemIds.length
                 menuRow.appendChild(
                     ? cargoQuery(
                     td
                );


                        'StallMenuItems',
            }
        );


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


                        'menu_item_id IN (' +
        tbody.appendChild(
                        makeInClause(
            menuRow
                            menuItemIds
        );
                        ) +
                        ')',


                        100


                    )
        table.appendChild(
                    : Promise.resolve(
            tbody
                        []
        );
                    )


            ] ).then(
        wrapper.appendChild(
                function ( results ) {
            table
        );


                    data.areas =
        compareRoot.appendChild(
                        results[ 0 ];
            wrapper
        );


                    data.menuItems =
                        results[ 1 ];


                    return data;
        const note =
 
            document.createElement(
                 }
                 'p'
             );
             );


         }
         note.className =
    ).then(
            'stall-compare-note';
        function ( data ) {


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




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


    }


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


    function renderMessage(
        message
    ) {


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




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


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


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




            /*
        compareRoot.appendChild(
            * Offering
             p
            * placement単位
        );
            */
             const offeringsByPlacement =
                {};


    }


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


                        return (
    /* =====================================
                            Number(
    * データ取得
                                a.sort_order || 0
    * ===================================== */
                            ) -
                            Number(
                                b.sort_order || 0
                            )
                        );


                    }
    const placementIds =
                )
        getPlacementIds();
                .forEach(
                    function ( offering ) {


                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );


    if (
        placementIds.length <
        MIN_COMPARE
    ) {


                        if (
        renderMessage(
                            !offeringsByPlacement[
            '比較する出店を2件以上選択してください。'
                                placementId
        );
                            ]
                        ) {


                            offeringsByPlacement[
        return;
                                placementId
                            ] = [];


                        }
    }




                        offeringsByPlacement[
    /*
                            placementId
    * STEP 1
                        ].push(
    * Placementを直接取得
                            offering
    */
                        );
    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 (' +
            * localStorage順を維持
        makeInClause(
            */
             placementIds
             const compareData =
        ) +
                placementIds.map(
        ')',
                    function (
                        placementId
                    ) {


                        const placement =
        100
                            placementMap[
                                placementId
                            ] || null;


    ).then(
        function ( placements ) {


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


                            return {


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


                                placement:
                                    null,


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


                                festival:
                                    null,


                                venue:
            /*
                                    null,
            * STEP 2
            */
            return Promise.all( [


                                area:
                stallIds.length
                                    null,
                    ? cargoQuery(


                                menus:
                        'Stalls',
                                    []


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


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


                        100


                        const stall =
                    )
                            stallMap[
                    : Promise.resolve(
                                String(
                        []
                                    placement.stall_id
                    ),
                                )
                            ] || null;




                        const festival =
                festivalIds.length
                            festivalMap[
                    ? cargoQuery(
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                        'Festivals',


                         const venue =
                         'festival_id=festival_id,' +
                            venueMap[
                        'name=festival_name,' +
                                String(
                        '_pageName=page_name',
                                    placement.venue_id
                                )
                            ] || null;


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


                         let area = null;
                         100


                    )
                    : Promise.resolve(
                        []
                    ),


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


                        }
                venueIds.length
                    ? cargoQuery(


                        'Venues',


                         const offerings =
                         'venue_id=venue_id,' +
                            offeringsByPlacement[
                        'name=venue_name,' +
                                placementId
                        'area_id=area_id,' +
                            ] || [];
                        '_pageName=page_name',


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


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


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




            return {
                cargoQuery(


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


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


                menuName:
                    'placement_id IN (' +
                     menu.menu_name ||
                    makeInClause(
                     '商品',
                        placementIds
                     ) +
                     ')',


                    100


                 category:
                 )
                    menu.item_category ||
                    '',


            ] ).then(
                function ( results ) {


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


                        placements:
                            placements,


                /*
                        stalls:
                * 比較用価格
                            results[ 0 ],
                */
                priceValue:
                    toFiniteNumber(
                        offering.price
                    ),


                        festivals:
                            results[ 1 ],


                servingQuantity:
                        venues:
                    cleanNumber(
                             results[ 2 ],
                        offering
                             .serving_quantity
                    ),


                        offerings:
                            results[ 3 ]


                servingUnit:
                     };
                     offering
                        .serving_unit ||
                    '',


                }
            );


        }
    ).then(
        function ( data ) {


                /*
            const areaIds =
                * 表示用単位価格
                 uniqueIds(
                */
                     data.venues.map(
                 unitPrice:
                        function ( row ) {
                     getUnitPrice(
                            return row.area_id;
                         offering
                         }
                     ),
                     )
                );




                /*
            const menuItemIds =
                * 比較用単位価格
                 uniqueIds(
                */
                     data.offerings.map(
                 unitPriceValue:
                        function ( row ) {
                     getUnitPriceValue(
                            return row.menu_item_id;
                         offering
                         }
                     ),
                     )
                );




                availability:
            /*
                    formatAvailability(
            * STEP 3
                        offering
            */
                            .availability
            return Promise.all( [
                    ),


                areaIds.length
                    ? cargoQuery(


                verification:
                         'Areas',
                    formatVerification(
                         offering
                            .verification_status
                    ),


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


                isLowestPrice:
                        'area_id IN (' +
                    false,
                        makeInClause(
                            areaIds
                        ) +
                        ')',


                        100


                isLowestUnitPrice:
                    )
                     false
                    : Promise.resolve(
                        []
                     ),


            };


        }
                menuItemIds.length
    );
                    ? cargoQuery(


                        'StallMenuItems',


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


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


    placement:
                        100
        placement,


    stall:
                    )
        stall,
                    : Promise.resolve(
                        []
                    )


    festival:
            ] ).then(
        festival,
                function ( results ) {
 
                    data.areas =
                        results[ 0 ];


    venue:
                    data.menuItems =
        venue,
                        results[ 1 ];


    area:
                    return data;
        area,


    menus:
                }
        menus
            );


};
        }
    ).then(
        function ( data ) {


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


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


function markBestPrices(
            const stallMap =
    compareData
                mapBy(
) {
                    data.stalls,
                    'stall_id'
                );


    const allMenus = [];


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


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


    function normalizeCompareText(
            const venueMap =
        value
                mapBy(
    ) {
                    data.venues,
                    'venue_id'
                );


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


        let text =
            const areaMap =
            String(
                mapBy(
                 value
                    data.areas,
            ).trim();
                    'area_id'
                 );


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


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


        }


        /*
            /*
        * 連続空白を1つにする
            * Offering
        */
            * placement単位
        text =
            */
            text.replace(
            const offeringsByPlacement =
                 /\s+/g,
                 {};
                ' '
 
            );


        /*
            data.offerings
        * 英字商品名にも対応
                .slice()
        */
                .sort(
        text =
                    function ( a, b ) {
            text.toLowerCase();


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


    }
                    }
                )
                .forEach(
                    function ( offering ) {


                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );


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


    compareData.forEach(
                        if (
        function ( data ) {
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {


            if (
                            offeringsByPlacement[
                !data.menus ||
                                placementId
                !Array.isArray(
                            ] = [];
                    data.menus
                )
            ) {
                return;
            }


                        }


            data.menus.forEach(
                function ( menu ) {


                    /*
                        offeringsByPlacement[
                    * 毎回初期化
                            placementId
                    */
                        ].push(
                    menu.isLowestPrice =
                            offering
                         false;
                         );


                     menu.isLowestUnitPrice =
                     }
                        false;
                );




                    /*
            /*
                    * 比較用の商品名
            * localStorage順を維持
                    */
            */
                    menu.compareMenuName =
            const compareData =
                        normalizeCompareText(
                placementIds.map(
                            menu.menuName
                    function (
                         );
                         placementId
                    ) {


                        const placement =
                            placementMap[
                                placementId
                            ] || null;


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


                        if ( !placement ) {


                    allMenus.push(
                            return {
                        menu
                    );


                }
                                placementId:
            );
                                    placementId,


        }
                                placement:
    );
                                    null,


                                stall:
                                    null,


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


    const groups = {};
                                venue:
                                    null,


                                area:
                                    null,


    allMenus.forEach(
                                menus:
        function ( menu ) {
                                    []


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


                        }


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


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


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


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


            if (
                !groups[
                    groupKey
                ]
            ) {


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


            }


                        let area = null;


            groups[
                groupKey
            ].push(
                menu
            );


        }
                        if (
    );
                            venue &&
                            venue.area_id
                        ) {


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


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


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


            const menus =
                        const offerings =
                groups[
                            offeringsByPlacement[
                    groupKey
                                placementId
                ];
                            ] || [];




            /* =============================
/*
            * 2出店以上あるか確認
* Placementに紐づくメニューを生成
            *
*/
            * 同じ出店内だけの商品比較は
const menus =
            * 「最安」としない
    offerings.map(
            * ============================= */
        function ( offering ) {


             const placementIds =
             const menu =
                 [
                 menuMap[
                     ...new Set(
                     String(
                         menus.map(
                         offering.menu_item_id
                            function ( menu ) {
                    )
                ] || {};


                                return String(
                                    menu.placementId
                                );


                            }
            return {
                        )
                    )
                ];


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


            if (
                placementIds.length < 2
            ) {


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


            }


                category:
                    menu.item_category ||
                    '',


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


            const priceCandidates =
                /*
                 menus.filter(
                * 表示価格
                     function ( menu ) {
                */
                 price:
                    cleanNumber(
                        offering.price
                     ),


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


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




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


                                return String(
                                    menu.placementId
                                );


                            }
                servingUnit:
                         )
                    offering
                     )
                         .serving_unit ||
                ];
                     '',
 




            if (
                /*
                pricePlacementIds.length >= 2
                * 表示用単位価格
            ) {
                */
                unitPrice:
                    getUnitPrice(
                        offering
                    ),


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


                                return menu
                /*
                                    .priceValue;
                * 比較用単位価格
                */
                unitPriceValue:
                    getUnitPriceValue(
                        offering
                    ),


                            }
                        )
                    );


                availability:
                    formatAvailability(
                        offering
                            .availability
                    ),


                priceCandidates.forEach(
                    function ( menu ) {


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


                            menu.isLowestPrice =
                                true;


                        }
                isLowestPrice:
                    false,


                    }
                );


            }
                isLowestUnitPrice:
                    false


            };


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


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


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


                    }
    placementId:
                );
        placementId,


    placement:
        placement,


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


                                return String(
    festival:
                                    menu.placementId
        festival,
                                );


                            }
    venue:
                        )
        venue,
                    )
                ];


    area:
        area,


            if (
    menus:
                unitPricePlacementIds.length >= 2
        menus
            ) {


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


                                return menu
}
                                    .unitPriceValue;
);
                       


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


 
function markBestPrices(
                unitPriceCandidates.forEach(
                    function ( menu ) {
 
                        /*
                        * 割り算による
                        * 浮動小数誤差対策
                        */
                        if (
                            Math.abs(
                                menu.unitPriceValue -
                                lowestUnitPrice
                            ) <
                            0.000001
                        ) {
 
                            menu.isLowestUnitPrice =
                                true;
 
                        }
 
                    }
                );
 
            }
 
        }
    );
 
}
 
/* =====================================
* 商品別比較サマリー
*
* 同じ商品名 + 同じ単位でグループ化
* ===================================== */
 
function renderProductGroupSummary(
     compareData
     compareData
) {
) {


     const comparePage =
     const allMenus = [];
        document.getElementById(
            'stall-compare-page'
        );
 
    if (
        !comparePage
    ) {
        return;
    }




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


     function normalizeText(
     function normalizeCompareText(
         value
         value
     ) {
     ) {
6,181行目: 6,211行目:
             ).trim();
             ).trim();


 
        /*
        * 全角・半角などを可能な範囲で統一
        */
         if (
         if (
             typeof text.normalize ===
             typeof text.normalize ===
6,194行目: 6,226行目:
         }
         }


 
        /*
        * 連続空白を1つにする
        */
         text =
         text =
             text.replace(
             text.replace(
6,201行目: 6,235行目:
             );
             );


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


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


function appendStallLinks(
     }
     container,
    items
) {


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


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


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


             if (
             if (
                 seen[
                 !data.menus ||
                     key
                !Array.isArray(
                 ]
                     data.menus
                 )
             ) {
             ) {
                 return;
                 return;
6,244行目: 6,263行目:




             seen[
             data.menus.forEach(
                 key
                 function ( menu ) {
            ] = true;


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


            stalls.push(
                    menu.isLowestUnitPrice =
                {
                         false;
                    name:
                         item.stallName,


                    page:
                        item.stallPage
                }
            );
        }
    );


                    /*
                    * 比較用の商品名
                    */
                    menu.compareMenuName =
                        normalizeCompareText(
                            menu.menuName
                        );
                    /*
                    * 比較用単位
                    */
                    menu.compareUnit =
                        normalizeCompareText(
                            menu.servingUnit
                        );
                    allMenus.push(
                        menu
                    );
                }
            );
        }
    );
    /* =================================
    * 商品名+単位ごとのグループ
    *
    * 例:
    *
    * たこ焼き + 個
    * 焼きそば + パック
    * りんご飴 + 本
    * ================================= */
    const groups = {};


     stalls.forEach(
 
         function (
     allMenus.forEach(
            stall,
         function ( menu ) {
            index
        ) {


             /*
             /*
             * 2件目以降の区切り
             * 商品名が無ければ比較しない
             */
             */
             if (
             if (
                 index > 0
                 !menu.compareMenuName
             ) {
             ) {
 
                 return;
                 container.appendChild(
                    document.createTextNode(
                        '・'
                    )
                );
 
             }
             }




             /*
             /*
             * ページが存在する場合
             * 単位が無ければ比較しない
             * リンクにする
             *
            * 「同じ商品名+同じ単位」
            * が条件だから
             */
             */
             if (
             if (
                 stall.page
                 !menu.compareUnit
             ) {
             ) {
                return;
            }


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


                 link.href =
            const groupKey =
                    mw.util.getUrl(
                 menu.compareMenuName +
                        stall.page
                '||' +
                    );
                menu.compareUnit;


                link.textContent =
                    stall.name;


                 link.className =
            if (
                     'stall-product-group-stall-link';
                 !groups[
                     groupKey
                ]
            ) {


                groups[
                    groupKey
                ] = [];


                container.appendChild(
            }
                    link
                );


            } else {


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


         }
         }
     );
     );


}


     /* =================================
     /* =================================
     * 商品グループ作成
     * グループごとに判定
     * ================================= */
     * ================================= */


     const groups = {};
     Object.keys(
        groups
    ).forEach(
        function ( groupKey ) {


            const menus =
                groups[
                    groupKey
                ];


    compareData.forEach(
        function ( data ) {


             if (
             /* =============================
                !data.menus ||
            * 2出店以上あるか確認
                !Array.isArray(
            *
                    data.menus
            * 同じ出店内だけの商品比較は
                )
            * 「最安」としない
            ) {
            * ============================= */
                return;
            }


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


            data.menus.forEach(
                                return String(
                function ( menu ) {
                                    menu.placementId
                                );


                    const menuName =
                            }
                         normalizeText(
                         )
                            menu.menuName
                    )
                        );
                ];


                    const unit =
                        normalizeText(
                            menu.servingUnit
                        );


            if (
                placementIds.length < 2
            ) {


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


            }


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


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


                    if (
            const priceCandidates =
                        !groups[
                menus.filter(
                            key
                     function ( menu ) {
                        ]
                     ) {


                         groups[
                         return (
                             key
                             menu.priceValue !==
                         ] = {
                                null &&
                            Number.isFinite(
                                menu.priceValue
                            )
                         );


                            menuName:
                    }
                                menuName,
                );


                            unit:
                                unit,


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


                        };
                                return String(
                                    menu.placementId
                                );


                     }
                            }
                        )
                     )
                ];




                    groups[
            if (
    key
                pricePlacementIds.length >= 2
].items.push(
            ) {
    {


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


        stallName:
                                return menu
            (
                                    .priceValue;
                data.stall &&
                data.stall.stall_name
            )
                ? data.stall.stall_name
                : '屋台',


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


        menu:
            menu


    }
                priceCandidates.forEach(
);
                    function ( menu ) {


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


                            menu.isLowestPrice =
                                true;


    const groupKeys =
                        }
        Object.keys(
            groups
        );


                    }
                );


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




    /* =================================
            /* =============================
    * サマリー全体
            * 最安単位価格
    * ================================= */
            *
            * 同商品+同単位で
            * price / quantity を比較
            * ============================= */


    const summary =
            const unitPriceCandidates =
        document.createElement(
                menus.filter(
            'section'
                    function ( menu ) {
        );


    summary.className =
                        return (
        'stall-product-group-summary';
                            menu.unitPriceValue !==
                                null &&
                            Number.isFinite(
                                menu.unitPriceValue
                            )
                        );


                    }
                );


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


    title.className =
            const unitPricePlacementIds =
        'stall-product-group-summary-title';
                [
                    ...new Set(
                        unitPriceCandidates.map(
                            function ( menu ) {


    title.textContent =
                                return String(
        '商品別比較サマリー';
                                    menu.placementId
                                );


 
                            }
    summary.appendChild(
                        )
        title
                    )
    );
                ];




    /* =================================
            if (
    * 各商品グループ
                unitPricePlacementIds.length >= 2
    * ================================= */
            ) {


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


            const group =
                                return menu
                groups[
                                    .unitPriceValue;
                    key
                ];


            const items =
                            }
                group.items;
                        )
                    );




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


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


                             }
                             menu.isLowestUnitPrice =
                        )
                                true;
                    )
                ];


                        }


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


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


        }
    );


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


            const heading =
/* =====================================
                document.createElement(
* 商品別比較サマリー
                    'h3'
*
                );
* 同じ商品名 + 同じ単位でグループ化
* ===================================== */


            heading.className =
function renderProductGroupSummary(
                'stall-product-group-name';
    compareData
) {


            heading.textContent =
    const comparePage =
                group.menuName +
        document.getElementById(
                ' / ' +
            'stall-compare-page'
                group.unit;
        );


    if (
        !comparePage
    ) {
        return;
    }


            card.appendChild(
                heading
            );


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


            /* =============================
    function normalizeText(
            * 比較店舗数
        value
            * ============================= */
    ) {


             const count =
        if (
                document.createElement(
             value === undefined ||
                    'div'
            value === null
                );
        ) {
            return '';
        }


             count.className =
        let text =
                 'stall-product-group-count';
             String(
                 value
            ).trim();


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


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


             card.appendChild(
             text =
                 count
                text.normalize(
            );
                    'NFKC'
                 );


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


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


stallList.className =
        text =
    'stall-product-group-stalls';
            text.replace(
                /\s+/g,
                ' '
            );


        return text;


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


stallListLabel.className =
/* =================================
    'stall-product-group-label';
* 屋台ページリンクを生成
* ================================= */


stallListLabel.textContent =
function appendStallLinks(
     '対象店舗:';
     container,
    items
) {


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


stallList.appendChild(
    stallListLabel
);


    items.forEach(
        function ( item ) {


/*
            /*
* 屋台名をリンクとして追加
            * page_nameがある場合は
*/
            * page_nameで重複判定
appendStallLinks(
            *
    stallList,
            * 無い場合は名前で判定
    items
            */
);
            const key =
                item.stallPage
                    ? 'page:' +
                      item.stallPage
                    : 'name:' +
                      item.stallName;


card.appendChild(
    stallList
);
            /* =============================
            * 1店舗しかない場合
            * ============================= */


             if (
             if (
                 placementIds.length < 2
                 seen[
                    key
                ]
             ) {
             ) {
                return;
            }


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


                 notice.className =
            seen[
                    'stall-product-group-notice';
                 key
            ] = true;


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


            stalls.push(
                {
                    name:
                        item.stallName,


                card.appendChild(
                    page:
                    notice
                        item.stallPage
                );
                }
            );


            }
        }
    );




             /* =============================
    stalls.forEach(
            * 最安価格の商品
        function (
            * ============================= */
             stall,
            index
        ) {


             const lowestPriceItems =
             /*
                 items.filter(
            * 2件目以降の区切り
                    function ( item ) {
            */
            if (
                 index > 0
            ) {


                        return (
                container.appendChild(
                            item.menu
                    document.createTextNode(
                                .isLowestPrice ===
                        '・'
                            true
                    )
                        );
                );


                    }
            }
                );




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


                 const lowestPrice =
                 const link =
                    lowestPriceItems[
                     document.createElement(
                        0
                         'a'
                    ].menu.priceValue;
 
 
                const row =
                     document.createElement(
                         'div'
                     );
                     );


                 row.className =
                 link.href =
                     'stall-product-group-best';
                     mw.util.getUrl(
 
                         stall.page
 
                const label =
                    document.createElement(
                         'span'
                     );
                     );


                 label.className =
                 link.textContent =
                     'stall-product-group-label';
                     stall.name;


                 label.textContent =
                 link.className =
                     '最安価格:';
                     'stall-product-group-stall-link';




                 const value =
                 container.appendChild(
                    document.createElement(
                     link
                        'strong'
                    );
 
                value.textContent =
                    lowestPrice +
                    '円';
 
 
                row.appendChild(
                     label
                 );
                 );


                row.appendChild(
            } else {
                    value
                );


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


            }


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


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


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


const shopLabel =
    const groups = {};
    document.createElement(
        'span'
    );


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


shopLabel.textContent =
    compareData.forEach(
    '最安:';
        function ( data ) {


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


shopRow.appendChild(
    shopLabel
);


            data.menus.forEach(
                function ( menu ) {


/*
                    const menuName =
* 最安店舗をリンク表示
                        normalizeText(
*/
                            menu.menuName
appendStallLinks(
                        );
    shopRow,
    lowestPriceItems
);


                    const unit =
                        normalizeText(
                            menu.servingUnit
                        );


card.appendChild(
    shopRow
);


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




            /* =============================
                    const key =
            * 最安単位価格
                        menuName.toLowerCase() +
            * ============================= */
                        '||' +
                        unit.toLowerCase();


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


                        return (
                    if (
                            item.menu
                        !groups[
                                .isLowestUnitPrice ===
                             key
                             true
                         ]
                         );
                     ) {
 
                     }
                );


                        groups[
                            key
                        ] = {


            if (
                            menuName:
                lowestUnitItems.length > 0
                                menuName,
            ) {


                const unitPrice =
                            unit:
                    lowestUnitItems[
                                unit,
                        0
                    ].menu.unitPriceValue;


                            items:
                                []


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


                    }


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


                row.className =
                    groups[
                    'stall-product-group-best-unit';
    key
].items.push(
    {


        placementId:
            String(
                menu.placementId
            ),


                 const label =
        stallName:
                    document.createElement(
            (
                        'span'
                data.stall &&
                    );
                 data.stall.stall_name
            )
                ? data.stall.stall_name
                : '屋台',


                 label.className =
        /*
                    'stall-product-group-label';
        * 屋台ページ名
        */
        stallPage:
            (
                 data.stall &&
                data.stall.page_name
            )
                ? data.stall.page_name
                : '',


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


    }
);


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


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




                row.appendChild(
    const groupKeys =
                    label
        Object.keys(
                );
            groups
        );


                row.appendChild(
                    value
                );


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


                card.appendChild(
                    row
                );


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


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


            summary.appendChild(
    summary.className =
                card
        'stall-product-group-summary';
            );


        }
    );


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


     /*
     title.className =
    * 比較表の一番上へ追加
         'stall-product-group-summary-title';
    */
    comparePage.insertBefore(
         summary,
        comparePage.firstChild
    );


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


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


markBestPrices(
    summary.appendChild(
     compareData
        title
);
     );




renderComparison(
    /* =================================
    compareData
    * 各商品グループ
);
    * ================================= */


    groupKeys.forEach(
        function ( key ) {


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


        }
            const items =
    ).catch(
                group.items;
        function ( error ) {


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


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


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


        }
                            }
    );
                        )
                    )
                ];




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


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


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


    const statusSelect = document.querySelector(
            /* =============================
        'select[name="FestivalStallPlacement[status]"]'
            * 商品名
    );
            * ============================= */


    if (statusSelect) {
            const heading =
        Array.from(statusSelect.options).forEach(function (option) {
                document.createElement(
            if (statusLabels[option.value]) {
                    'h3'
                 option.textContent = statusLabels[option.value];
                 );
            }
        });
    }


    const verificationLabels = {
            heading.className =
        verified: '確認済み',
                'stall-product-group-name';
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };


    const verificationSelect = document.querySelector(
            heading.textContent =
        'select[name="FestivalStallPlacement[verification_status]"]'
                group.menuName +
    );
                ' / ' +
                group.unit;


    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) {
            card.appendChild(
    yearInput.inputMode = 'numeric';
                heading
    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);
            const count =
    yearInput.addEventListener('change', validateYear);
                document.createElement(
    yearInput.addEventListener('invalid', validateYear);
                    'div'
                );


    validateYear();
            count.className =
}
                'stall-product-group-count';
   
const positionLabels = {
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};


const positionSelect = document.querySelector(
            count.textContent =
    'select[name="FestivalStallPlacement[position_status]"]'
                '比較店舗:' +
);
                placementIds.length +
                '';


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) {
             card.appendChild(
    accuracyInput.inputMode = 'numeric';
                 count
 
    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 stallList =
}
     document.createElement(
   
        'div'
     const openingTimeInput = document.querySelector(
    );
    'input[name="FestivalStallPlacement[opening_time]"]'
);


const closingTimeInput = document.querySelector(
stallList.className =
     'input[name="FestivalStallPlacement[closing_time]"]'
     'stall-product-group-stalls';
);


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


function setupTimeValidation(input, label) {
const stallListLabel =
     if (!input) {
     document.createElement(
         return;
         'span'
     }
     );


     input.placeholder = '例:10:00';
stallListLabel.className =
     'stall-product-group-label';
 
stallListLabel.textContent =
    '対象店舗:';


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


        input.setCustomValidity('');
stallList.appendChild(
    stallListLabel
);


        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);
*/
appendStallLinks(
     stallList,
     items
);


    validateTime();
}


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


const longitudeInput = document.querySelector(
            /* =============================
    'input[name="FestivalStallPlacement[longitude]"]'
            * 1店舗しかない場合
);
            * ============================= */
 
            if (
                placementIds.length < 2
            ) {


function setupCoordinateValidation(input, label, min, max) {
                const notice =
    if (!input) {
                    document.createElement(
        return null;
                        'div'
    }
                    );


    input.inputMode = 'decimal';
                notice.className =
                    'stall-product-group-notice';


    const validateCoordinate = function () {
                notice.textContent =
        const value = input.value.trim();
                    '比較対象が1店舗のみです。';


        input.setCustomValidity('');


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


                return;
             }
             }


             const otherInput =
 
                 input === latitudeInput
            /* =============================
                     ? longitudeInput
            * 最安価格の商品
                    : latitudeInput;
            * ============================= */
 
             const lowestPriceItems =
                 items.filter(
                    function ( item ) {
 
                        return (
                            item.menu
                                .isLowestPrice ===
                            true
                        );
 
                     }
                );
 


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


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


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


        /*
                const row =
        * 範囲チェック
                    document.createElement(
        */
                        'div'
        const number = Number(value);
                    );
 
                row.className =
                    'stall-product-group-best';


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


    input.addEventListener(
                const label =
        'input',
                    document.createElement(
        validateCoordinate
                        'span'
    );
                    );


    input.addEventListener(
                label.className =
        'change',
                    'stall-product-group-label';
        validateCoordinate
    );


    input.addEventListener(
                label.textContent =
        'invalid',
                    '最安価格:';
        validateCoordinate
    );


    validateCoordinate();


    /*
                const value =
    * position_status変更時に
                    document.createElement(
    * 再チェックできるよう関数を返す。
                        'strong'
    */
                    );
    return validateCoordinate;
}


const validateLatitude =
                value.textContent =
    setupCoordinateValidation(
                    lowestPrice +
        latitudeInput,
                    '';
        '緯度',
        20,
        46
    );


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


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


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


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


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


/*
                /*
  * 位置情報の状態を変更した場合、
  * 最安店舗リンク
* 緯度・経度を再検証する。
  */
  */
if (positionSelect) {
const shopRow =
     positionSelect.addEventListener(
     document.createElement(
         'change',
         'div'
        function () {
    );
            if (validateLatitude) {
                validateLatitude();
            }


            if (validateLongitude) {
shopRow.className =
                validateLongitude();
     'stall-product-group-shop';
            }
        }
    );
}
   
    const sourceUrlInput = document.querySelector(
     'input[name="FestivalStallPlacement[source_url]"]'
);


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


    const validateSourceUrl = function () {
const shopLabel =
         const value = sourceUrlInput.value.trim();
    document.createElement(
         'span'
    );


        sourceUrlInput.setCustomValidity('');
shopLabel.className =
    'stall-product-group-label';


        if (value === '') {
shopLabel.textContent =
            return;
    '最安:';
        }


        try {
            const url = new URL(value);


            if (url.protocol !== 'http:' && url.protocol !== 'https:') {
shopRow.appendChild(
                sourceUrlInput.setCustomValidity(
    shopLabel
                    '情報元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(
appendStallLinks(
     'input[name="FestivalStallPlacement[sort_order]"]'
     shopRow,
     lowestPriceItems
);
);


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


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


        sortOrderInput.setCustomValidity('');
            }


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


    sortOrderInput.addEventListener('input', validateSortOrder);
            /* =============================
    sortOrderInput.addEventListener('change', validateSortOrder);
            * 最安単位価格
    sortOrderInput.addEventListener('invalid', validateSortOrder);
            * ============================= */


    validateSortOrder();
            const lowestUnitItems =
}
                items.filter(
   
                    function ( item ) {
});


/**
                        return (
* FestivalStallPlacement - 最終確認日の未来日チェック
                            item.menu
*/
                                .isLowestUnitPrice ===
(function () {
                            true
'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';
            if (
                lowestUnitItems.length > 0
            ) {


function getVisibleInput() {
                const unitPrice =
const widget = dateInput.closest('.oo-ui-widget');
                    lowestUnitItems[
                        0
                    ].menu.unitPriceValue;


if (!widget) {
return null;
}


return widget.querySelector('input[type="text"]');
                /*
}
                * 小数表示調整
                */
                const displayUnitPrice =
                    Math.round(
                        unitPrice *
                        100
                    ) /
                    100;


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


if (!widget) {
                const row =
return null;
                    document.createElement(
}
                        'div'
                    );


let error = widget.parentNode.querySelector(
                row.className =
'.stall-last-confirmed-error'
                    'stall-product-group-best-unit';
);


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


widget.insertAdjacentElement('afterend', error);
                const label =
}
                    document.createElement(
                        'span'
                    );


return error;
                label.className =
}
                    'stall-product-group-label';


function showError() {
                label.textContent =
const visibleInput = getVisibleInput();
                    '最安単位価格:';
const error = getErrorElement();


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


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


if (dateInput.validity.rangeOverflow) {
                value.textContent =
const maxDate = dateInput.max.replace(/-/g, '/');
                    displayUnitPrice +
                    '/' +
                    group.unit;


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


error.textContent = message;
                row.appendChild(
error.hidden = false;
                    label
                );


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


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


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


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


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


showError();
            summary.appendChild(
                card
            );


const visibleInput = getVisibleInput();
        }
    );


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


/*
    /*
* ユーザーが日付を修正したら
    * 比較表の一番上へ追加
* 有効になった時点でエラーを消す。
    */
*/
    comparePage.insertBefore(
const form = dateInput.form;
        summary,
        comparePage.firstChild
    );
 
}
 
/* =================================
* 最安値を自動判定
* ================================= */
 
markBestPrices(
    compareData
);


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


        if (
renderComparison(
            !currentWidget ||
    compareData
            !currentWidget.contains(event.target)
);
        ) {
 
            return;
 
/*
* 詳細比較表を描画した後に
* 商品別サマリーを追加
*/
renderProductGroupSummary(
    compareData
);
 
         }
         }
    ).catch(
        function ( error ) {


        window.setTimeout(function () {
            console.error(
            if (dateInput.validity.valid) {
                'Placement比較データ取得エラー:',
                 clearError();
                 error
            } else if (
            );
                dateInput.validity.rangeOverflow
 
             ) {
 
                 showError();
             renderMessage(
             }
                 '比較データの取得中にエラーが発生しました。'
        }, 0);
             );
    }


    form.addEventListener(
         }
         'input',
        handleDateChange
     );
     );


    form.addEventListener(
        'change',
        handleDateChange
    );


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


if (document.readyState === 'loading') {
} );
document.addEventListener(
 
'DOMContentLoaded',
$(function () {
setupLastConfirmedValidation
const statusLabels = {
);
    active: '出店中・出店予定',
} else {
    cancelled: '出店中止',
setupLastConfirmedValidation();
    unknown: '未確認'
}
};


mw.hook('wikipage.content').add(function () {
    const statusSelect = document.querySelector(
setupLastConfirmedValidation();
        'select[name="FestivalStallPlacement[status]"]'
});
    );
})();


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


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


function setupDuplicateWarning() {
    const verificationSelect = document.querySelector(
const form = document.getElementById('pfForm');
        'select[name="FestivalStallPlacement[verification_status]"]'
    );


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


const table = form.querySelector('.formtable');
if (yearInput) {
    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;


if (!table) {
    const validateYear = function () {
return;
        const value = yearInput.value.trim();
}


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


const warning = document.createElement('div');
    yearInput.addEventListener('input', validateYear);
    yearInput.addEventListener('change', validateYear);
    yearInput.addEventListener('invalid', validateYear);


warning.className = 'stall-duplicate-warning';
    validateYear();
warning.setAttribute('role', 'status');
}
warning.hidden = true;
   
const positionLabels = {
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};


/*
const positionSelect = document.querySelector(
* 表の中ではなく、表の直前に置く。
    'select[name="FestivalStallPlacement[position_status]"]'
* 警告表示でフォームの列幅を崩さない。
);
*/
table.insertAdjacentElement('beforebegin', warning);


let timer = null;
if (positionSelect) {
let requestId = 0;
    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]"]'
);


function escapeCargo(value) {
if (accuracyInput) {
return String(value).replace(/'/g, "''");
    accuracyInput.inputMode = 'numeric';
}


function getField(name) {
    const validateAccuracy = function () {
return form.querySelector(
        const value = accuracyInput.value.trim();
'[name="FestivalStallPlacement[' +
 
name +
        if (value !== '' && !/^\d+$/.test(value)) {
']"]'
            accuracyInput.setCustomValidity(
);
                '位置精度は0以上の整数で入力してください(例:10)'
}
            );
        } else {
            accuracyInput.setCustomValidity('');
        }
    };


function cargoQuery(tableName, fields, where, limit) {
    accuracyInput.addEventListener('input', validateAccuracy);
return api.get({
    accuracyInput.addEventListener('change', validateAccuracy);
action: 'cargoquery',
    accuracyInput.addEventListener('invalid', validateAccuracy);
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) {
    validateAccuracy();
return item.title || item;
}
});
   
});
    const openingTimeInput = document.querySelector(
}
    'input[name="FestivalStallPlacement[opening_time]"]'
);


function resolveId(
const closingTimeInput = document.querySelector(
tableName,
    'input[name="FestivalStallPlacement[closing_time]"]'
idField,
);
nameField,
value
) {
if (!value) {
return Promise.resolve(null);
}


if (/^\d+$/.test(value)) {
const timePattern = /^([01]\d|2[0-3]):[0-5]\d$/;
return Promise.resolve(value);
}


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


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


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


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


function showFailure() {
    input.addEventListener('input', validateTime);
warning.replaceChildren();
    input.addEventListener('change', validateTime);
    input.addEventListener('invalid', validateTime);


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


text.textContent =
setupTimeValidation(openingTimeInput, '営業開始時刻');
'既存データの確認に失敗しました。' +
setupTimeValidation(closingTimeInput, '営業終了時刻');
'登録はできますが、重複がないかご確認ください。';
   
    const latitudeInput = document.querySelector(
    'input[name="FestivalStallPlacement[latitude]"]'
);


warning.appendChild(text);
const longitudeInput = document.querySelector(
warning.hidden = false;
    'input[name="FestivalStallPlacement[longitude]"]'
}
);


function showCandidates(rows, venueSpecified) {
function setupCoordinateValidation(input, label, min, max) {
warning.replaceChildren();
    if (!input) {
        return null;
    }


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


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


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


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


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


const labelElement =
            const otherInput =
document.createElement('span');
                input === latitudeInput
                    ? longitudeInput
                    : latitudeInput;


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


labelElement.textContent = label;
            return;
        }


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


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


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


container.appendChild(row);
    input.addEventListener(
}
        'input',
        validateCoordinate
    );


const title = document.createElement('strong');
    input.addEventListener(
        'change',
        validateCoordinate
    );


title.className =
    input.addEventListener(
'stall-duplicate-warning-title';
        'invalid',
        validateCoordinate
    );


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


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


const description =
const validateLatitude =
document.createElement('p');
    setupCoordinateValidation(
        latitudeInput,
        '緯度',
        20,
        46
    );


description.className =
const validateLongitude =
'stall-duplicate-warning-description';
    setupCoordinateValidation(
        longitudeInput,
        '経度',
        122,
        154
    );


description.textContent =
/*
venueSpecified
* 一方の座標を変更した場合、
? (
* 反対側のペア整合性も再検証する。
'出店場所が異なる場合は新規登録して構いません。' +
*/
'下の既存データと同じ場所ではないか確認してください。'
if (
)
    latitudeInput &&
: (
    validateLongitude
'会場未指定のため、会場を問わず候補を確認しています。' +
) {
'出店場所が異なる場合は新規登録して構いません。' +
    latitudeInput.addEventListener(
'下の既存データと同じ場所ではないか確認してください。'
        'input',
);
        validateLongitude
    );


warning.appendChild(description);
    latitudeInput.addEventListener(
        'change',
        validateLongitude
    );
}


const list = document.createElement('div');
if (
    longitudeInput &&
    validateLatitude
) {
    longitudeInput.addEventListener(
        'input',
        validateLatitude
    );


list.className =
    longitudeInput.addEventListener(
'stall-duplicate-candidate-list';
        'change',
        validateLatitude
    );
}


/*
/*
* placement_id順に並べる
* 位置情報の状態を変更した場合、
*/
* 緯度・経度を再検証する。
rows.sort(function (a, b) {
*/
return (
if (positionSelect) {
Number(a.placement_id) -
    positionSelect.addEventListener(
Number(b.placement_id)
        'change',
);
        function () {
});
            if (validateLatitude) {
                validateLatitude();
            }
 
            if (validateLongitude) {
                validateLongitude();
            }
        }
    );
}
   
    const sourceUrlInput = document.querySelector(
    'input[name="FestivalStallPlacement[source_url]"]'
);


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


card.className =
    const validateSourceUrl = function () {
'stall-duplicate-candidate';
        const value = sourceUrlInput.value.trim();


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


header.className =
        if (value === '') {
'stall-duplicate-candidate-header';
            return;
        }


const heading =
        try {
document.createElement('strong');
            const url = new URL(value);


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


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


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


/*
if (sortOrderInput) {
* 出店場所
    sortOrderInput.inputMode = 'numeric';
*/
 
addDetail(
    const validateSortOrder = function () {
card,
        const value = sortOrderInput.value.trim();
'出店場所',
displayValue(
row.location_note,
'場所メモなし'
)
);


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


/*
        if (value !== '' && !/^\d+$/.test(value)) {
* 緯度・経度
            sortOrderInput.setCustomValidity(
*/
                '表示順は0以上の整数で入力してください(例:1)'
let coordinates =
            );
'位置情報なし';
        }
    };


if (
    sortOrderInput.addEventListener('input', validateSortOrder);
row.latitude !== undefined &&
    sortOrderInput.addEventListener('change', validateSortOrder);
row.latitude !== null &&
    sortOrderInput.addEventListener('invalid', validateSortOrder);
String(row.latitude).trim() !== '' &&
row.longitude !== undefined &&
row.longitude !== null &&
String(row.longitude).trim() !== ''
) {
coordinates =
String(row.latitude) +
', ' +
String(row.longitude);
}


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


/*
/**
  * 位置精度
  * FestivalStallPlacement - 最終確認日の未来日チェック
  */
  */
let accuracy = '未確認';
(function () {
'use strict';


if (
function setupLastConfirmedValidation() {
row.position_accuracy_m !== undefined &&
const dateInputs = document.querySelectorAll(
row.position_accuracy_m !== null &&
'input[name="FestivalStallPlacement[last_confirmed]"]'
String(row.position_accuracy_m).trim() !== ''
);
) {
 
accuracy =
dateInputs.forEach(function (dateInput) {
String(row.position_accuracy_m) +
if (dateInput.dataset.lastConfirmedValidation === '1') {
' m';
return;
}
}


addDetail(
dateInput.dataset.lastConfirmedValidation = '1';
card,
'位置精度',
accuracy
);


/*
function getVisibleInput() {
* 出店状態
const widget = dateInput.closest('.oo-ui-widget');
*/
addDetail(
card,
'出店状態',
statusLabels[
row.status
] ||
displayValue(
row.status,
'未確認'
)
);


/*
if (!widget) {
* 最終確認日
return null;
*/
}
let lastConfirmed = '未確認';


if (
return widget.querySelector('input[type="text"]');
row.last_confirmed !== undefined &&
}
row.last_confirmed !== null &&
String(row.last_confirmed).trim() !== ''
) {
lastConfirmed =
String(row.last_confirmed)
.replace(/-/g, '/');
}


addDetail(
function getErrorElement() {
card,
const widget = dateInput.closest('.oo-ui-widget');
'最終確認日',
lastConfirmed
);


/*
if (!widget) {
* 確認状態
return null;
*/
}
addDetail(
card,
'確認状態',
verificationLabels[
row.verification_status
] ||
displayValue(
row.verification_status,
'未確認'
)
);


/*
let error = widget.parentNode.querySelector(
* 既存ページへのリンク
'.stall-last-confirmed-error'
*/
);
const actions =
document.createElement('div');


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


const link =
widget.insertAdjacentElement('afterend', error);
document.createElement('a');
}


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


link.target = '_blank';
function showError() {
link.rel = 'noopener';
const visibleInput = getVisibleInput();
const error = getErrorElement();


link.textContent =
if (!visibleInput || !error) {
'既存データを確認';
return;
}


actions.appendChild(link);
let message;
card.appendChild(actions);


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


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


const footer =
error.textContent = message;
document.createElement('div');
error.hidden = false;


footer.className =
visibleInput.setAttribute('aria-invalid', 'true');
'stall-duplicate-warning-footer';
}


footer.textContent =
function clearError() {
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';
const visibleInput = getVisibleInput();
const error = getErrorElement();


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


warning.hidden = false;
if (visibleInput) {
}
visibleInput.removeAttribute('aria-invalid');
}
}


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


/*
showError();
* 毎回現在のinput/selectを取得する。
* Page Formsが要素を作り直しても対応できる。
*/
const stall = getField('stall_id');
const festival = getField('festival_id');
const venue = getField('venue_id');
const year = getField('year');


if (
const visibleInput = getVisibleInput();
!stall ||
!festival ||
!venue ||
!year
) {
clearWarning();
return;
}


const stallValue = stall.value.trim();
if (visibleInput) {
const festivalValue = festival.value.trim();
window.setTimeout(function () {
const venueValue = venue.value.trim();
visibleInput.focus();
const yearValue = year.value.trim();
}, 0);
}
});


if (
/*
!stallValue ||
* ユーザーが日付を修正したら
!festivalValue ||
* 有効になった時点でエラーを消す。
!/^\d{4}$/.test(yearValue)
*/
) {
const form = dateInput.form;
clearWarning();
return;
}


/*
if (form) {
* async / await は使わず、
    function handleDateChange(event) {
* Promise の then() で処理する。
        const currentWidget =
*/
            dateInput.closest('.oo-ui-widget');
Promise.all([
resolveId(
'Stalls',
'stall_id',
'name',
stallValue
),
resolveId(
'Festivals',
'festival_id',
'name',
festivalValue
),
venueValue !== ''
? resolveId(
'Venues',
'venue_id',
'_pageName',
venueValue
)
: Promise.resolve(null)
])
.then(function (ids) {
if (currentRequest !== requestId) {
return null;
}


if (
        if (
!ids[0] ||
            !currentWidget ||
!ids[1] ||
            !currentWidget.contains(event.target)
(
        ) {
venueValue !== '' &&
            return;
!ids[2]
        }
)
) {
clearWarning();
return null;
}


let where =
        window.setTimeout(function () {
'festival_id=' +
            if (dateInput.validity.valid) {
ids[1] +
                clearError();
' AND year=' +
            } else if (
yearValue +
                dateInput.validity.rangeOverflow
' AND stall_id=' +
            ) {
ids[0];
                showError();
            }
        }, 0);
    }


if (ids[2]) {
    form.addEventListener(
where +=
        'input',
' AND venue_id=' +
        handleDateChange
ids[2];
    );
}


return cargoQuery(
    form.addEventListener(
'FestivalStallPlacements',
        'change',
'placement_id=placement_id,' +
        handleDateChange
'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候補:',
    * Page Forms のカレンダー選択では
where,
    * visible input に blur が発生する。
rows
    * blur は通常バブルしないため capture=true。
);
    */
    form.addEventListener(
        'blur',
        handleDateChange,
        true
    );
}
});
}


if (rows.length === 0) {
if (document.readyState === 'loading') {
clearWarning();
document.addEventListener(
return;
'DOMContentLoaded',
}
setupLastConfirmedValidation
);
} else {
setupLastConfirmedValidation();
}


/*
mw.hook('wikipage.content').add(function () {
* 今回は警告のみ。
setupLastConfirmedValidation();
* 同条件の既存データをすべて表示する。
});
*/
})();
const rawPageName =
String(
mw.config.get('wgPageName') ||
''
);


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


const currentPlacementPage =
const api = new mw.Api();
markerIndex >= 0
? rawPageName
.slice(
markerIndex +
formEditMarker.length
)
.replace(/_/g, ' ')
.trim()
: '';


const filteredRows =
function setupDuplicateWarning() {
currentPlacementPage
const form = document.getElementById('pfForm');
? rows.filter(function (row) {
return (
String(
row.page_name ||
''
)
.replace(/_/g, ' ')
.trim() !==
currentPlacementPage
);
})
: rows;


if (filteredRows.length === 0) {
if (!form) {
clearWarning();
return;
return;
}
}


showCandidates(
if (form.dataset.duplicateWarningV2 === '1') {
filteredRows,
return;
venueValue !== ''
}
);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);


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


function scheduleCheck() {
if (!table) {
window.clearTimeout(timer);
return;
}


timer = window.setTimeout(
form.dataset.duplicateWarningV2 = '1';
checkDuplicates,
 
300
const warning = document.createElement('div');
);
 
}
warning.className = 'stall-duplicate-warning';
warning.setAttribute('role', 'status');
warning.hidden = true;


/*
/*
* form自身へイベントを設定する。
* 表の中ではなく、表の直前に置く。
* dropdownが後から置き換わっても拾える。
* 警告表示でフォームの列幅を崩さない。
*/
*/
form.addEventListener('change', function (event) {
table.insertAdjacentElement('beforebegin', warning);
const name = event.target.name || '';


if (
let timer = null;
name ===
let requestId = 0;
'FestivalStallPlacement[stall_id]' ||
name ===
'FestivalStallPlacement[festival_id]' ||
name ===
'FestivalStallPlacement[venue_id]' ||
name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


form.addEventListener('input', function (event) {
function escapeCargo(value) {
if (
return String(value).replace(/'/g, "''");
event.target.name ===
}
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


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


if (document.readyState === 'loading') {
function cargoQuery(tableName, fields, where, limit) {
document.addEventListener(
return api.get({
'DOMContentLoaded',
action: 'cargoquery',
setupDuplicateWarning
tables: tableName,
);
fields: fields,
} else {
where: where,
setupDuplicateWarning();
limit: limit || 50,
}
format: 'json'
 
}).then(function (data) {
mw.hook('pf.formSetupAfter').add(
if (
setupDuplicateWarning
!data ||
);
!Array.isArray(data.cargoquery)
});
) {
return [];
}
 
return data.cargoquery.map(function (item) {
return item.title || item;
});
});
}


/*
function resolveId(
* FestivalStallMenuOffering
tableName,
* 入力検証・日本語表示
idField,
*/
nameField,
(function () {
value
    'use strict';
) {
if (!value) {
return Promise.resolve(null);
}


    var FORM_ID = 'pfForm';
if (/^\d+$/.test(value)) {
return Promise.resolve(value);
}


    var availabilityLabels = {
return cargoQuery(
        available: '販売中',
tableName,
        unknown: '未確認'
idField + '=resolved_id',
    };
nameField +
"='" +
escapeCargo(value) +
"'",
2
).then(function (rows) {
if (rows.length !== 1) {
console.warn(
'IDを一意に取得できません:',
tableName,
value,
rows
);


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


    function isOfferingField(element) {
return String(rows[0].resolved_id);
        return !!(
});
            element &&
}
            element.name &&
            element.name.indexOf(
                'FestivalStallMenuOffering['
            ) === 0
        );
    }


    function isTemplateField(element) {
function clearWarning() {
        return !!(
warning.hidden = true;
            element &&
warning.replaceChildren();
            element.name &&
}
            element.name.indexOf('[num]') !== -1
        );
    }


    function fieldNameEndsWith(element, suffix) {
function showFailure() {
        return !!(
warning.replaceChildren();
            element &&
            element.name &&
            element.name.slice(-suffix.length) === suffix
        );
    }


    function localizeSelect(select, labels) {
const text = document.createElement('div');
        if (!select) {
            return;
        }


        Array.from(select.options).forEach(
text.textContent =
            function (option) {
'既存データの確認に失敗しました。' +
                if (
'登録はできますが、重複がないかご確認ください。';
                    Object.prototype.hasOwnProperty.call(
                        labels,
                        option.value
                    ) &&
                    option.textContent !==
                        labels[option.value]
                ) {
                    option.textContent =
                        labels[option.value];
                }
            }
        );
    }


    function validatePrice(input) {
warning.appendChild(text);
        var value = input.value.trim();
warning.hidden = false;
}


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


        if (
const positionLabels = {
            value !== '' &&
    exact: '位置確認済み',
            !/^\d+$/.test(value)
    approximate: 'おおよその位置',
        ) {
    unknown: '位置未確認'
            input.setCustomValidity(
};
                '価格は0以上の整数で入力してください(例:600)'
            );
        }
    }


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


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


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


        if (
function addDetail(container, label, value) {
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
const row = document.createElement('div');
        ) {
row.className =
            input.setCustomValidity(
'stall-duplicate-candidate-detail';
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
            );
        }
    }


    function validateLimitedQuantity(input) {
const labelElement =
        var value = input.value.trim();
document.createElement('span');


        input.setCustomValidity('');
labelElement.className =
'stall-duplicate-candidate-label';


        if (
labelElement.textContent = label;
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '限定数量は0以上の整数で入力してください(例:100)'
            );
        }
    }


    function validateSortOrder(input) {
const valueElement =
        var value = input.value.trim();
document.createElement('span');


        input.setCustomValidity('');
valueElement.className =
'stall-duplicate-candidate-value';


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


    function validateSourceUrl(input) {
row.appendChild(labelElement);
        var value = input.value.trim();
row.appendChild(valueElement);


        input.setCustomValidity('');
container.appendChild(row);
}


        if (value === '') {
const title = document.createElement('strong');
            return;
        }


        try {
title.className =
            var url = new URL(value);
'stall-duplicate-warning-title';


            if (
title.textContent =
                url.protocol !== 'http:' &&
venueSpecified
                url.protocol !== 'https:'
? (
            ) {
'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
                input.setCustomValidity(
rows.length +
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
'件あります。'
                );
)
            }
: (
        } catch (e) {
'⚠ 同じ祭り・開催年・屋台の既存データが' +
            input.setCustomValidity(
rows.length +
                '情報元URLを正しいURL形式で入力してください。'
'件あります。'
            );
);
        }
    }


    function validateLastConfirmed(input) {
warning.appendChild(title);
        var value = input.value;
        var max = input.max;


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


        if (
description.className =
            value !== '' &&
'stall-duplicate-warning-description';
            max !== '' &&
            value > max
        ) {
            input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
    }


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


        if (!widget) {
warning.appendChild(description);
            return null;
        }


        return widget.querySelector(
const list = document.createElement('div');
            'input[type="text"]'
        );
    }


    function getDateErrorElement(dateInput) {
list.className =
        var widget =
'stall-duplicate-candidate-list';
            dateInput.closest('.oo-ui-widget');


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


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


        if (
card.className =
            next &&
'stall-duplicate-candidate';
            next.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            return next;
        }


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


        /*
header.className =
        * 既存の最終確認日エラー用CSSも利用する。
'stall-duplicate-candidate-header';
        */
        error.className =
            'stall-last-confirmed-error ' +
            'stall-offering-last-confirmed-error';


        error.setAttribute(
const heading =
            'role',
document.createElement('strong');
            'alert'
        );


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


        widget.insertAdjacentElement(
header.appendChild(heading);
            'afterend',
            error
        );


        return error;
card.appendChild(header);
    }


    function showDateError(dateInput) {
/*
        var visibleInput =
* 出店場所
            getVisibleDateInput(dateInput);
*/
addDetail(
card,
'出店場所',
displayValue(
row.location_note,
'場所メモなし'
)
);


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


        if (!error) {
/*
            return;
* 緯度・経度
        }
*/
let coordinates =
'位置情報なし';


        var maxDate =
if (
            dateInput.max
row.latitude !== undefined &&
                ? dateInput.max.replace(/-/g, '/')
row.latitude !== null &&
                : '';
String(row.latitude).trim() !== '' &&
 
row.longitude !== undefined &&
        if (
row.longitude !== null &&
            dateInput.validity.rangeOverflow ||
String(row.longitude).trim() !== ''
            (
) {
                dateInput.value &&
coordinates =
                dateInput.max &&
String(row.latitude) +
                dateInput.value > dateInput.max
', ' +
            )
String(row.longitude);
        ) {
}
            error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
            error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }


        error.hidden = false;
addDetail(
card,
'緯度・経度',
coordinates
);


        if (visibleInput) {
/*
            visibleInput.setAttribute(
* 位置精度
                'aria-invalid',
*/
                'true'
let accuracy = '未確認';
            );
        }
    }


    function clearDateError(dateInput) {
if (
        var visibleInput =
row.position_accuracy_m !== undefined &&
            getVisibleDateInput(dateInput);
row.position_accuracy_m !== null &&
String(row.position_accuracy_m).trim() !== ''
) {
accuracy =
String(row.position_accuracy_m) +
' m';
}


        var widget =
addDetail(
            dateInput.closest('.oo-ui-widget');
card,
'位置精度',
accuracy
);


        var error = null;
/*
 
* 出店状態
        if (
*/
            widget &&
addDetail(
            widget.nextElementSibling &&
card,
            widget.nextElementSibling.classList.contains(
'出店状態',
                'stall-offering-last-confirmed-error'
statusLabels[
            )
row.status
        ) {
] ||
            error =
displayValue(
                widget.nextElementSibling;
row.status,
        }
'未確認'
)
);


        if (error) {
/*
            error.hidden = true;
* 最終確認日
            error.textContent = '';
*/
        }
let lastConfirmed = '未確認';


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


    function getLimitedQuantityInput(
addDetail(
        checkbox,
card,
        form
'最終確認日',
    ) {
lastConfirmed
        if (!checkbox || !checkbox.name) {
);
            return null;
        }


        var quantityName =
/*
            checkbox.name.replace(
* 確認状態
                /\[limited\]\[value\]$/,
*/
                '[limited_quantity]'
addDetail(
            );
card,
'確認状態',
verificationLabels[
row.verification_status
] ||
displayValue(
row.verification_status,
'未確認'
)
);


        return Array.from(
/*
            form.querySelectorAll(
* 既存ページへのリンク
                'input[name^="FestivalStallMenuOffering["]'
*/
            )
const actions =
        ).find(
document.createElement('div');
            function (input) {
                return input.name === quantityName;
            }
        ) || null;
    }


    function updateLimitedState(
actions.className =
        checkbox,
'stall-duplicate-candidate-actions';
        form,
        clearWhenOff
    ) {
        var quantityInput =
            getLimitedQuantityInput(
                checkbox,
                form
            );


        if (!quantityInput) {
const link =
            return;
document.createElement('a');
        }


        if (checkbox.checked) {
link.href =
            quantityInput.disabled = false;
mw.util.getUrl(row.page_name);
            quantityInput.removeAttribute(
                'aria-disabled'
            );
        } else {
            if (clearWhenOff) {
                quantityInput.value = '';
            }


            quantityInput.setCustomValidity('');
link.target = '_blank';
            quantityInput.disabled = true;
link.rel = 'noopener';
            quantityInput.setAttribute(
                'aria-disabled',
                'true'
            );
        }
    }


    function validateField(element) {
link.textContent =
        if (
'既存データを確認';
            !isOfferingField(element) ||
            isTemplateField(element)
        ) {
            return;
        }


        if (
actions.appendChild(link);
            fieldNameEndsWith(
card.appendChild(actions);
                element,
                '[price]'
            )
        ) {
            validatePrice(element);
            return;
        }


        if (
list.appendChild(card);
            fieldNameEndsWith(
});
                element,
 
                '[serving_quantity]'
warning.appendChild(list);
            )
        ) {
            validateServingQuantity(element);
            return;
        }


        if (
const footer =
            fieldNameEndsWith(
document.createElement('div');
                element,
                '[limited_quantity]'
            )
        ) {
            validateLimitedQuantity(element);
            return;
        }


        if (
footer.className =
            fieldNameEndsWith(
'stall-duplicate-warning-footer';
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
            return;
        }


        if (
footer.textContent =
            fieldNameEndsWith(
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';
                element,
                '[source_url]'
            )
        ) {
            validateSourceUrl(element);
            return;
        }


        if (
warning.appendChild(footer);
            fieldNameEndsWith(
                element,
                '[last_confirmed]'
            )
        ) {
            validateLastConfirmed(element);


            if (element.validity.valid) {
warning.hidden = false;
                clearDateError(element);
}
            }


            return;
function checkDuplicates() {
        }
const currentRequest = ++requestId;
    }


    function initializeFields(form) {
/*
        /*
* 毎回現在のinput/selectを取得する。
        * 販売状態を日本語化。
* Page Formsが要素を作り直しても対応できる。
        * [num]も変更しておくことで、
*/
        * 後から追加されるmultipleにも反映される。
const stall = getField('stall_id');
        */
const festival = getField('festival_id');
        form.querySelectorAll(
const venue = getField('venue_id');
            'select[name^="FestivalStallMenuOffering["]' +
const year = getField('year');
            '[name$="[availability]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    availabilityLabels
                );
            }
        );


        /*
if (
        * 確認状態を日本語化。
!stall ||
        */
!festival ||
        form.querySelectorAll(
!venue ||
            'select[name^="FestivalStallMenuOffering["]' +
!year
            '[name$="[verification_status]"]'
) {
        ).forEach(
clearWarning();
            function (select) {
return;
                localizeSelect(
}
                    select,
                    verificationLabels
                );
            }
        );


        /*
const stallValue = stall.value.trim();
        * 数値入力向けキーボード。
const festivalValue = festival.value.trim();
        */
const venueValue = venue.value.trim();
        form.querySelectorAll(
const yearValue = year.value.trim();
            '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(
if (
            'input[name^="FestivalStallMenuOffering["]' +
!stallValue ||
            '[name$="[serving_quantity]"]'
!festivalValue ||
        ).forEach(
!/^\d{4}$/.test(yearValue)
            function (input) {
) {
                input.inputMode = 'decimal';
clearWarning();
            }
return;
        );
}
 
/*
* async / await は使わず、
* Promise の then() で処理する。
*/
Promise.all([
resolveId(
'Stalls',
'stall_id',
'name',
stallValue
),
resolveId(
'Festivals',
'festival_id',
'name',
festivalValue
),
venueValue !== ''
? resolveId(
'Venues',
'venue_id',
'_pageName',
venueValue
)
: Promise.resolve(null)
])
.then(function (ids) {
if (currentRequest !== requestId) {
return null;
}


        form.querySelectorAll(
if (
            'input[name^="FestivalStallMenuOffering["]' +
!ids[0] ||
            '[name$="[source_url]"]'
!ids[1] ||
        ).forEach(
(
            function (input) {
venueValue !== '' &&
                input.inputMode = 'url';
!ids[2]
            }
)
        );
) {
clearWarning();
return null;
}


        /*
let where =
        * 限定数量欄のON/OFF。
'festival_id=' +
        */
ids[1] +
        form.querySelectorAll(
' AND year=' +
            'input[type="checkbox"]' +
yearValue +
            '[name^="FestivalStallMenuOffering["]' +
' AND stall_id=' +
            '[name$="[limited][value]"]'
ids[0];
        ).forEach(
 
            function (checkbox) {
if (ids[2]) {
                updateLimitedState(
where +=
                    checkbox,
' AND venue_id=' +
                    form,
ids[2];
                    false
}
                );
            }
        );


        /*
return cargoQuery(
        * 現在値を一度検証。
'FestivalStallPlacements',
        * [num]は除外。
'placement_id=placement_id,' +
        */
'location_note=location_note,' +
        form.querySelectorAll(
'latitude=latitude,' +
            '[name^="FestivalStallMenuOffering["]'
'longitude=longitude,' +
        ).forEach(
'position_status=position_status,' +
            function (element) {
'position_accuracy_m=position_accuracy_m,' +
                validateField(element);
'status=status,' +
            }
'verification_status=verification_status,' +
        );
'last_confirmed=last_confirmed,' +
    }
'_pageName=page_name',
where,
50
).then(function (rows) {
if (currentRequest !== requestId) {
return;
}


    function setupOfferingValidation() {
console.log(
        var form =
'FestivalStallPlacement候補:',
            document.getElementById(
where,
                FORM_ID
rows
            );
);


        if (!form) {
if (rows.length === 0) {
            return;
clearWarning();
        }
return;
}


        /*
/*
        * wikipage.content 等で再度呼ばれても
* 今回は警告のみ。
        * イベントを二重登録しない。
* 同条件の既存データをすべて表示する。
        */
*/
        if (
const rawPageName =
            form.dataset
String(
                .offeringValidationInitialized ===
mw.config.get('wgPageName') ||
            '1'
''
        ) {
);
            initializeFields(form);
            return;
        }


        form.dataset
const formEditMarker =
            .offeringValidationInitialized =
'/FestivalStallPlacement/';
            '1';


        /*
const markerIndex =
        * multipleで後から追加された項目にも効くよう
rawPageName.indexOf(
        * form側でイベント委譲。
formEditMarker
        */
);
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
            }
        );


        form.addEventListener(
const currentPlacementPage =
            'change',
markerIndex >= 0
            function (event) {
? rawPageName
                var target =
.slice(
                    event.target;
markerIndex +
formEditMarker.length
)
.replace(/_/g, ' ')
.trim()
: '';


                if (!isOfferingField(target)) {
const filteredRows =
                    return;
currentPlacementPage
                }
? rows.filter(function (row) {
return (
String(
row.page_name ||
''
)
.replace(/_/g, ' ')
.trim() !==
currentPlacementPage
);
})
: rows;


                if (
if (filteredRows.length === 0) {
                    target.type === 'checkbox' &&
clearWarning();
                    fieldNameEndsWith(
return;
                        target,
}
                        '[limited][value]'
                    )
                ) {
                    updateLimitedState(
                        target,
                        form,
                        true
                    );
                }


                validateField(target);
showCandidates(
            }
filteredRows,
        );
venueValue !== ''
);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);


/*
showFailure();
* Page Forms のカレンダー選択では、
});
* visible input に blur が発生する場合がある。
* 対応する非表示 date input を取得して再検証する。
*/
form.addEventListener(
    'blur',
    function (event) {
var target = event.target;
 
if (
    !target ||
    typeof target.closest !== 'function'
) {
    return;
}
}


var widget =
function scheduleCheck() {
    target.closest('.oo-ui-widget');
window.clearTimeout(timer);


        if (!widget) {
timer = window.setTimeout(
            return;
checkDuplicates,
        }
300
);
}


        var dateInput =
/*
            widget.querySelector(
* form自身へイベントを設定する。
                'input[type="date"]' +
* dropdownが後から置き換わっても拾える。
                '[name^="FestivalStallMenuOffering["]' +
*/
                '[name$="[last_confirmed]"]'
form.addEventListener('change', function (event) {
            );
const name = event.target.name || '';


        if (
if (
            !dateInput ||
name ===
            isTemplateField(dateInput)
'FestivalStallPlacement[stall_id]' ||
        ) {
name ===
            return;
'FestivalStallPlacement[festival_id]' ||
        }
name ===
'FestivalStallPlacement[venue_id]' ||
name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


        window.setTimeout(
form.addEventListener('input', function (event) {
            function () {
if (
                validateField(dateInput);
event.target.name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


                if (dateInput.validity.valid) {
scheduleCheck();
                    clearDateError(dateInput);
}
                } else {
                    showDateError(dateInput);
                }
            },
            0
        );
    },
    true
);


        /*
if (document.readyState === 'loading') {
        * invalidイベントは通常bubbleしないため
document.addEventListener(
        * capture=trueで取得する。
'DOMContentLoaded',
        */
setupDuplicateWarning
        form.addEventListener(
);
            'invalid',
} else {
            function (event) {
setupDuplicateWarning();
                var target =
}
                    event.target;


                if (
mw.hook('pf.formSetupAfter').add(
                    !isOfferingField(target) ||
setupDuplicateWarning
                    isTemplateField(target)
);
                ) {
});
                    return;
                }


                validateField(target);
/*
* FestivalStallMenuOffering
* 入力検証・日本語表示
*/
(function () {
    'use strict';


                if (
    var FORM_ID = 'pfForm';
                    fieldNameEndsWith(
                        target,
                        '[last_confirmed]'
                    )
                ) {
                    event.preventDefault();


                    showDateError(target);
    var availabilityLabels = {
        available: '販売中',
        unknown: '未確認'
    };


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


                    if (visibleInput) {
    function isOfferingField(element) {
                        window.setTimeout(
        return !!(
                            function () {
            element &&
                                visibleInput.focus();
            element.name &&
                            },
            element.name.indexOf(
                            0
                 'FestivalStallMenuOffering['
                        );
             ) === 0
                    }
                 }
             },
            true
         );
         );
    }


         /*
    function isTemplateField(element) {
        * 「販売商品を追加」でDOMが増えた場合の初期化。
         return !!(
        */
            element &&
         var mutationTimer = null;
            element.name &&
            element.name.indexOf('[num]') !== -1
         );
    }


         var observer =
    function fieldNameEndsWith(element, suffix) {
             new MutationObserver(
         return !!(
                function () {
             element &&
                    window.clearTimeout(
            element.name &&
                        mutationTimer
            element.name.slice(-suffix.length) === suffix
                    );
        );
    }


                    mutationTimer =
    function localizeSelect(select, labels) {
                        window.setTimeout(
        if (!select) {
                            function () {
            return;
                                initializeFields(
        }
                                    form
 
                                );
        Array.from(select.options).forEach(
                            },
            function (option) {
                            100
                if (
                         );
                    Object.prototype.hasOwnProperty.call(
                        labels,
                        option.value
                    ) &&
                    option.textContent !==
                        labels[option.value]
                ) {
                    option.textContent =
                         labels[option.value];
                 }
                 }
            );
        observer.observe(
            form,
            {
                childList: true,
                subtree: true
             }
             }
         );
         );
    }


         initializeFields(form);
    function validatePrice(input) {
    }
         var value = input.value.trim();
 
        input.setCustomValidity('');


    if (
        if (
        document.readyState ===
            value !== '' &&
        'loading'
            !/^\d+$/.test(value)
    ) {
        ) {
        document.addEventListener(
            input.setCustomValidity(
            'DOMContentLoaded',
                '価格は0以上の整数で入力してください(例:600)'
             setupOfferingValidation
             );
        );
        }
    } else {
        setupOfferingValidation();
     }
     }


     mw.hook(
     function validateServingQuantity(input) {
         'wikipage.content'
         var value = input.value.trim();
    ).add(
        setupOfferingValidation
    );


    mw.hook(
        input.setCustomValidity('');
         'pf.formSetupAfter'
 
    ).add(
         if (value === '') {
         setupOfferingValidation
            return;
    );
         }


})();
        if (
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
        ) {
            input.setCustomValidity(
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
            );
        }
    }


    function validateLimitedQuantity(input) {
        var value = input.value.trim();


mw.loader.using('mediawiki.api').then(function () {
        input.setCustomValidity('');
    'use strict';


    if (window.__festivalStallMenuFilterInitialized) {
        if (
         return;
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '限定数量は0以上の整数で入力してください(例:100)'
            );
         }
     }
     }


     window.__festivalStallMenuFilterInitialized = true;
     function validateSortOrder(input) {
        var value = input.value.trim();


    const STALL_SELECTOR =
         input.setCustomValidity('');
         'select[name="FestivalStallPlacement[stall_id]"]';


    const MENU_SELECTOR =
        if (
         'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
            value !== '' &&
            !/^\d+$/.test(value)
         ) {
            input.setCustomValidity(
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    }


     const TEMPLATE_MENU_SELECTOR =
     function validateSourceUrl(input) {
         'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
         var value = input.value.trim();


    const api = new mw.Api();
        input.setCustomValidity('');


    let requestSerial = 0;
        if (value === '') {
    let observerTimer = null;
            return;
    let applying = false;
        }


    const menuCache = {};
        try {
            var url = new URL(value);


/*
            if (
* FestivalStallPlacement フォーム以外では
                url.protocol !== 'http:' &&
* この連動機能を起動しない。
                url.protocol !== 'https:'
*/
            ) {
const stallSelect =
                input.setCustomValidity(
    document.querySelector(STALL_SELECTOR);
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            input.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
        }
    }


if (!stallSelect) {
    function validateLastConfirmed(input) {
    return;
        var value = input.value;
}
        var max = input.max;


/*
        input.setCustomValidity('');
* Page Formsの雛形が持つ全商品optionを最初に保存
*/
const templateSelect =
    document.querySelector(TEMPLATE_MENU_SELECTOR);


if (!templateSelect) {
        if (
    console.error(
            value !== '' &&
        '販売商品の雛形SELECTが見つかりません。'
            max !== '' &&
    );
            value > max
     return;
        ) {
}
            input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
     }


     const masterOptions =
     function getVisibleDateInput(dateInput) {
        [...templateSelect.options].map(
        var widget =
            function (option) {
            dateInput.closest('.oo-ui-widget');
                return option.cloneNode(true);
            }
        );


    function cargoQuote(value) {
        if (!widget) {
        return "'" + String(value)
            return null;
            .replace(/\\/g, '\\\\')
        }
            .replace(/'/g, "\\'") + "'";
    }


    function cargoRows(res) {
         return widget.querySelector(
         return (res.cargoquery || []).map(
             'input[type="text"]'
             function (row) {
                return row.title || {};
            }
         );
         );
     }
     }


     function getRealMenuSelects() {
     function getDateErrorElement(dateInput) {
         return [
         var widget =
             ...document.querySelectorAll(
             dateInput.closest('.oo-ui-widget');
                MENU_SELECTOR
            )
        ].filter(function (select) {
            return !select.name.includes('[num]');
        });
    }


    function resolveStallId(stallName) {
        if (!widget) {
            return null;
        }


         return api.get({
         var next =
            action: 'cargoquery',
             widget.nextElementSibling;
            format: 'json',
            tables: 'Stalls',
            fields:
                'stall_id=stall_id,' +
                'name=name',
             where:
                'name=' +
                cargoQuote(stallName),
            limit: 20
        }).then(function (res) {


             const rows =
        if (
                 cargoRows(res);
             next &&
            next.classList.contains(
                 'stall-offering-last-confirmed-error'
            )
        ) {
            return next;
        }


             if (rows.length === 1) {
        var error =
                return rows[0].stall_id;
             document.createElement('div');
            }


            /*
        /*
            * 同名表示が
        * 既存の最終確認日エラー用CSSも利用する。
            * 名前 (ID)
        */
            * になっている場合
        error.className =
            */
            'stall-last-confirmed-error ' +
            const match =
            'stall-offering-last-confirmed-error';
                String(stallName)
                    .match(/\((\d+)\)$/);


            if (!match) {
        error.setAttribute(
                throw new Error(
            'role',
                    '屋台を1件に特定できません: ' +
            'alert'
                    stallName
        );
                );
            }


            return match[1];
         error.hidden = true;
         });
    }


    function loadMenus(stallId) {
        widget.insertAdjacentElement(
            'afterend',
            error
        );


         const key =
         return error;
            String(stallId);
    }


        if (menuCache[key]) {
    function showDateError(dateInput) {
             return Promise.resolve(
        var visibleInput =
                menuCache[key]
             getVisibleDateInput(dateInput);
            );
        }


         return api.get({
         var error =
            action: 'cargoquery',
             getDateErrorElement(dateInput);
            format: 'json',
            tables: 'StallMenuItems',
fields:
    'menu_item_id=menu_item_id,' +
    'stall_id=stall_id,' +
    'name=name,' +
    'status=status',
             where:
                'stall_id=' +
                Number(stallId) +
                " AND status='active'",
            order_by:
    'menu_item_id',
            limit: 100
        }).then(function (res) {


            const rows =
        if (!error) {
                cargoRows(res);
            return;
        }


             menuCache[key] =
        var maxDate =
                 rows;
             dateInput.max
                ? dateInput.max.replace(/-/g, '/')
                 : '';


             return rows;
        if (
         });
            dateInput.validity.rangeOverflow ||
    }
            (
                dateInput.value &&
                dateInput.max &&
                dateInput.value > dateInput.max
            )
        ) {
             error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
         } else {
            error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }


    function optionBelongsToMenu(
         error.hidden = false;
         option,
        menu
    ) {


         const name =
         if (visibleInput) {
             String(menu.name || '');
             visibleInput.setAttribute(
                'aria-invalid',
                'true'
            );
        }
    }


         const id =
    function clearDateError(dateInput) {
             String(
         var visibleInput =
                menu.menu_item_id || ''
             getVisibleDateInput(dateInput);
            );


         const value =
         var widget =
             String(option.value || '');
             dateInput.closest('.oo-ui-widget');


         const text =
         var error = null;
            String(
                option.textContent || ''
            );


        /*
        * 商品名が一意
        */
         if (
         if (
             value === name ||
             widget &&
             text === name
             widget.nextElementSibling &&
            widget.nextElementSibling.classList.contains(
                'stall-offering-last-confirmed-error'
            )
         ) {
         ) {
             return true;
             error =
                widget.nextElementSibling;
         }
         }


         /*
         if (error) {
        * Page Formsによる
            error.hidden = true;
        * 同名商品の識別表示
             error.textContent = '';
        *
        }
        * たこ焼き (1)
        * たこ焼き (3)
        */
        const mapped =
             name + ' (' + id + ')';


         return (
         if (visibleInput) {
             value === mapped ||
             visibleInput.removeAttribute(
             text === mapped
                'aria-invalid'
         );
             );
         }
     }
     }


     function makeOptions(menus) {
     function getLimitedQuantityInput(
        checkbox,
        form
    ) {
        if (!checkbox || !checkbox.name) {
            return null;
        }


         const options = [];
         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,
         const blank =
        clearWhenOff
             masterOptions.find(
    ) {
                 function (option) {
         var quantityInput =
                    return (
             getLimitedQuantityInput(
                        option.value === ''
                 checkbox,
                    );
                 form
                 }
             );
             );


         if (blank) {
         if (!quantityInput) {
             options.push(
             return;
                 blank.cloneNode(true)
        }
 
        if (checkbox.checked) {
            quantityInput.disabled = false;
            quantityInput.removeAttribute(
                 'aria-disabled'
             );
             );
         } else {
         } else {
             options.push(
             if (clearWhenOff) {
                 new Option('', '')
                quantityInput.value = '';
            }
 
            quantityInput.setCustomValidity('');
            quantityInput.disabled = true;
            quantityInput.setAttribute(
                 'aria-disabled',
                'true'
             );
             );
         }
         }
    }


         menus.forEach(
    function validateField(element) {
             function (menu) {
         if (
             !isOfferingField(element) ||
            isTemplateField(element)
        ) {
            return;
        }


                const option =
        if (
                    masterOptions.find(
            fieldNameEndsWith(
                        function (candidate) {
                element,
                            return optionBelongsToMenu(
                '[price]'
                                candidate,
            )
                                menu
        ) {
                            );
            validatePrice(element);
                        }
            return;
                    );
        }


                if (option) {
        if (
                    options.push(
            fieldNameEndsWith(
                        option.cloneNode(true)
                element,
                    );
                '[serving_quantity]'
                } else {
            )
                    console.warn(
        ) {
                        'Page Formsのoptionを特定できません:',
            validateServingQuantity(element);
                        menu
             return;
                    );
         }
                }
             }
         );


         return options;
         if (
    }
            fieldNameEndsWith(
                element,
                '[limited_quantity]'
            )
        ) {
            validateLimitedQuantity(element);
            return;
        }


    function optionSignature(select) {
        if (
            fieldNameEndsWith(
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
            return;
        }


         return [...select.options]
         if (
             .map(function (option) {
             fieldNameEndsWith(
                 return (
                 element,
                    option.value +
                '[source_url]'
                    '::' +
            )
                    option.textContent
        ) {
                );
             validateSourceUrl(element);
             })
             return;
             .join('||');
        }
    }


    function filterMenuSelects(
        if (
        menus,
            fieldNameEndsWith(
         clearSelection
                element,
    ) {
                '[last_confirmed]'
            )
         ) {
            validateLastConfirmed(element);


        const desiredTemplate =
            if (element.validity.valid) {
            makeOptions(menus);
                clearDateError(element);
            }


        const desiredSignature =
             return;
             desiredTemplate
        }
                .map(function (option) {
    }
                    return (
                        option.value +
                        '::' +
                        option.textContent
                    );
                })
                .join('||');


         applying = true;
    function initializeFields(form) {
         /*
        * 販売状態を日本語化。
        * [num]も変更しておくことで、
        * 後から追加されるmultipleにも反映される。
        */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[availability]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    availabilityLabels
                );
            }
        );


         getRealMenuSelects().forEach(
         /*
        * 確認状態を日本語化。
        */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[verification_status]"]'
        ).forEach(
             function (select) {
             function (select) {
                localizeSelect(
                    select,
                    verificationLabels
                );
            }
        );


                 const previousValue =
        /*
                    select.value;
        * 数値入力向けキーボード。
        */
        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["]' +
                * DOMを触らない
            '[name$="[serving_quantity]"]'
                */
        ).forEach(
                if (
            function (input) {
                    optionSignature(select) ===
                 input.inputMode = 'decimal';
                    desiredSignature
            }
                 ) {
        );
                    if (clearSelection &&
                        select.value !== '') {


                        select.value = '';
        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[source_url]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'url';
            }
        );


                        if (window.jQuery) {
        /*
                            jQuery(select)
        * 限定数量欄のON/OFF。
                                .trigger('change');
        */
                        }
        form.querySelectorAll(
                    }
            'input[type="checkbox"]' +
            '[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited][value]"]'
        ).forEach(
            function (checkbox) {
                updateLimitedState(
                    checkbox,
                    form,
                    false
                );
            }
        );


                    return;
        /*
                }
        * 現在値を一度検証。
 
        * [num]は除外。
                const newOptions =
        */
                    desiredTemplate.map(
        form.querySelectorAll(
                        function (option) {
            '[name^="FestivalStallMenuOffering["]'
                            return option
        ).forEach(
                                .cloneNode(true);
            function (element) {
                        }
                 validateField(element);
                    );
 
                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(
     function setupOfferingValidation() {
         clearSelection
         var form =
    ) {
            document.getElementById(
                FORM_ID
            );


         const stall =
         if (!form) {
            document.querySelector(
             return;
                STALL_SELECTOR
        }
             );


if (!stall) {
        /*
    return;
        * wikipage.content 等で再度呼ばれても
}
        * イベントを二重登録しない。
        */
        if (
            form.dataset
                .offeringValidationInitialized ===
            '1'
        ) {
            initializeFields(form);
            return;
        }


if (!stall.value) {
        form.dataset
    /*
            .offeringValidationInitialized =
    * 屋台が未選択なら、
            '1';
    * 進行中の古い非同期処理を無効化し、
    * 商品候補を空欄だけに戻す。
    */
    ++requestSerial;


    filterMenuSelects(
        /*
        [],
        * multipleで後から追加された項目にも効くよう
         true
        * form側でイベント委譲。
    );
        */
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
            }
         );


    return;
        form.addEventListener(
}
            'change',
            function (event) {
                var target =
                    event.target;


        const serial =
                if (!isOfferingField(target)) {
            ++requestSerial;
                    return;
                }


        const stallName =
                if (
            stall.value;
                    target.type === 'checkbox' &&
                    fieldNameEndsWith(
                        target,
                        '[limited][value]'
                    )
                ) {
                    updateLimitedState(
                        target,
                        form,
                        true
                    );
                }


        resolveStallId(
                validateField(target);
             stallName
             }
         )
         );
        .then(function (stallId) {


            if (
/*
                serial !==
* Page Forms のカレンダー選択では、
                requestSerial
* visible input に blur が発生する場合がある。
            ) {
* 対応する非表示 date input を取得して再検証する。
                return null;
*/
            }
form.addEventListener(
    'blur',
    function (event) {
var target = event.target;


            console.log(
if (
                '[屋台→商品V2]',
    !target ||
                stallName,
    typeof target.closest !== 'function'
                '→ stall_id=' +
) {
                stallId
    return;
            );
}


            return loadMenus(
var widget =
                stallId
    target.closest('.oo-ui-widget');
            );


         })
         if (!widget) {
         .then(function (menus) {
            return;
         }


             if (
        var dateInput =
                 !menus ||
             widget.querySelector(
                 serial !==
                 'input[type="date"]' +
                    requestSerial
                 '[name^="FestivalStallMenuOffering["]' +
            ) {
                 '[name$="[last_confirmed]"]'
                return;
            }
 
            console.log(
                 '[販売商品候補V2]',
                menus
             );
             );


             filterMenuSelects(
        if (
                menus,
             !dateInput ||
                clearSelection
            isTemplateField(dateInput)
             );
        ) {
             return;
        }


         })
         window.setTimeout(
        .catch(function (err) {
            function () {
                validateField(dateInput);


            console.error(
                if (dateInput.validity.valid) {
                 '[屋台→商品V2] エラー:',
                    clearDateError(dateInput);
                 err
                 } else {
             );
                    showDateError(dateInput);
         });
                 }
     }
             },
            0
         );
     },
    true
);


    /*
        /*
    * Page Formsによる
        * invalidイベントは通常bubbleしないため
    * option再生成を検出
        * capture=trueで取得する。
    */
        */
    function mutationTouchesMenus(
        form.addEventListener(
        mutation
            'invalid',
    ) {
            function (event) {
                var target =
                    event.target;


        const target =
                if (
            mutation.target;
                    !isOfferingField(target) ||
                    isTemplateField(target)
                ) {
                    return;
                }


        if (
                validateField(target);
            target.nodeType === 1 &&
            target.matches &&
            target.matches(MENU_SELECTOR)
        ) {
            return true;
        }


        for (
                if (
            const node of
                    fieldNameEndsWith(
            mutation.addedNodes
                        target,
        ) {
                        '[last_confirmed]'
                    )
                ) {
                    event.preventDefault();


            if (
                    showDateError(target);
                node.nodeType !== 1
            ) {
                continue;
            }


            if (
                    var visibleInput =
                node.matches &&
                        getVisibleDateInput(
                node.matches(MENU_SELECTOR)
                            target
            ) {
                        );
                return true;
            }


            if (
                    if (visibleInput) {
                node.querySelector &&
                        window.setTimeout(
                node.querySelector(
                            function () {
                     MENU_SELECTOR
                                visibleInput.focus();
                 )
                            },
             ) {
                            0
                return true;
                        );
            }
                     }
                 }
             },
            true
        );


            /*
        /*
            * SELECTの中にOPTIONが追加された
        * 「販売商品を追加」でDOMが増えた場合の初期化。
            */
        */
            if (
        var mutationTimer = null;
                node.tagName === 'OPTION' &&
                node.parentElement &&
                node.parentElement.matches &&
                node.parentElement.matches(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }
        }


         return false;
         var observer =
    }
            new MutationObserver(
                function () {
                    window.clearTimeout(
                        mutationTimer
                    );


    const observer =
                    mutationTimer =
        new MutationObserver(
                        window.setTimeout(
            function (mutations) {
                            function () {
 
                                initializeFields(
                if (applying) {
                                    form
                    return;
                                );
                            },
                            100
                        );
                 }
                 }
            );


                const touched =
        observer.observe(
                    mutations.some(
            form,
                        mutationTouchesMenus
            {
                    );
                 childList: true,
 
                 subtree: true
                if (!touched) {
                    return;
                 }
 
                clearTimeout(
                    observerTimer
                );
 
                 /*
                * Page Formsの再初期化が
                * 完了してから実行
                */
                observerTimer =
                    setTimeout(
                        function () {
                            refreshMenus(false);
                        },
                        250
                    );
             }
             }
         );
         );


    const form =
         initializeFields(form);
         document.getElementById(
    }
            'pfForm'
        ) || document.body;


     observer.observe(
     if (
         form,
         document.readyState ===
         {
         'loading'
            childList: true,
     ) {
            subtree: true
         document.addEventListener(
        }
             'DOMContentLoaded',
     );
            setupOfferingValidation
 
    /*
    * 屋台変更
    */
    const stall =
         document.querySelector(
             STALL_SELECTOR
         );
         );
 
     } else {
     function onStallChange() {
         setupOfferingValidation();
         refreshMenus(true);
     }
     }


        stall.addEventListener(
    mw.hook(
         'change',
         'wikipage.content'
         onStallChange
    ).add(
         setupOfferingValidation
     );
     );


     /*
     mw.hook(
    * 初期表示
        'pf.formSetupAfter'
    */
    ).add(
     refreshMenus(false);
        setupOfferingValidation
     );


        console.log(
})();
        '屋台→販売商品連動を初期化しました。'
    );


});


/*
mw.loader.using('mediawiki.api').then(function () {
* FestivalStallMenuOffering
* 同一Placement内の商品重複警告
*
* 保存は禁止しない。
*/
(function () {
     'use strict';
     'use strict';


     const MENU_SELECTOR =
     if (window.__festivalStallMenuFilterInitialized) {
         'select[name^="FestivalStallMenuOffering["]' +
         return;
        '[name$="[menu_item_id]"]';
    }


     function setupOfferingDuplicateWarning() {
     window.__festivalStallMenuFilterInitialized = true;
        const form =
            document.getElementById('pfForm');


         if (!form) {
    const STALL_SELECTOR =
            return;
         'select[name="FestivalStallPlacement[stall_id]"]';
        }


        /*
    const MENU_SELECTOR =
        * FestivalStallPlacementフォームだけを対象にする。
         'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
        */
         if (
            !form.querySelector(
                '[name="FestivalStallPlacement[stall_id]"]'
            )
        ) {
            return;
        }


         function getMenuSelects() {
    const TEMPLATE_MENU_SELECTOR =
            return [
         'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
                ...form.querySelectorAll(
                    MENU_SELECTOR
                )
            ].filter(function (select) {
                return !select.name.includes('[num]');
            });
        }


        function getWarning() {
    const api = new mw.Api();
            let warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );


            if (warning) {
    let requestSerial = 0;
                return warning;
    let observerTimer = null;
            }
    let applying = false;


            const firstSelect =
    const menuCache = {};
                getMenuSelects()[0];


            if (!firstSelect) {
/*
                return null;
* FestivalStallPlacement フォーム以外では
            }
* この連動機能を起動しない。
*/
const stallSelect =
    document.querySelector(STALL_SELECTOR);


            warning =
if (!stallSelect) {
                document.createElement('div');
    return;
 
}
            warning.className =
                'stall-offering-duplicate-warning';
 
            warning.setAttribute(
                'role',
                'status'
            );
 
            warning.hidden = true;
 
            warning.style.marginTop = '8px';
            warning.style.padding = '10px';
            warning.style.border =
                '1px solid #a2a9b1';
            warning.style.borderRadius = '4px';


/*
/*
  * 警告は個別の商品行ではなく、
  * Page Formsの雛形が持つ全商品optionを最初に保存
* 販売商品multiple全体の上部に表示する。
  */
  */
const wrapper =
const templateSelect =
     firstSelect.closest(
     document.querySelector(TEMPLATE_MENU_SELECTOR);
        '.multipleTemplateWrapper'
    );


const list =
if (!templateSelect) {
    wrapper
     console.error(
        ? wrapper.querySelector(
         '販売商品の雛形SELECTが見つかりません。'
            '.multipleTemplateList'
        )
        : null;
 
if (list) {
     list.insertAdjacentElement(
         'beforebegin',
        warning
    );
} else {
    const container =
        firstSelect.closest('fieldset') ||
        firstSelect.closest('td') ||
        firstSelect.parentNode;
 
    container.insertBefore(
        warning,
        container.firstChild
     );
     );
    return;
}
}


             return warning;
    const masterOptions =
         }
        [...templateSelect.options].map(
             function (option) {
                return option.cloneNode(true);
            }
         );


        function clearWarning() {
    function cargoQuote(value) {
             const warning =
        return "'" + String(value)
                form.querySelector(
             .replace(/\\/g, '\\\\')
                    '.stall-offering-duplicate-warning'
            .replace(/'/g, "\\'") + "'";
                );
    }


             if (!warning) {
    function cargoRows(res) {
                 return;
        return (res.cargoquery || []).map(
             function (row) {
                 return row.title || {};
             }
             }
        );
    }


             warning.hidden = true;
    function getRealMenuSelects() {
             warning.textContent = '';
        return [
         }
            ...document.querySelectorAll(
                MENU_SELECTOR
             )
        ].filter(function (select) {
             return !select.name.includes('[num]');
         });
    }


        function checkDuplicates() {
    function resolveStallId(stallName) {
            const selects =
                getMenuSelects();


             const counts = {};
        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) {


             selects.forEach(function (select) {
             const rows =
                const value =
                cargoRows(res);
                    String(
                        select.value || ''
                    ).trim();


                if (!value) {
            if (rows.length === 1) {
                    return;
                return rows[0].stall_id;
                }
            }


                 counts[value] =
            /*
                     (counts[value] || 0) + 1;
            * 同名表示が
            });
            * 名前 (ID)
            * になっている場合
            */
            const match =
                 String(stallName)
                     .match(/\((\d+)\)$/);


             const duplicates =
             if (!match) {
                 Object.keys(counts).filter(
                 throw new Error(
                     function (value) {
                     '屋台を1件に特定できません: ' +
                        return counts[value] > 1;
                     stallName
                     }
                 );
                 );
            if (duplicates.length === 0) {
                clearWarning();
                return;
             }
             }


             const warning =
             return match[1];
                getWarning();
        });
    }


            if (!warning) {
    function loadMenus(stallId) {
                return;
            }


             warning.textContent = '';
        const key =
             String(stallId);


             const title =
        if (menuCache[key]) {
                 document.createElement('strong');
             return Promise.resolve(
                 menuCache[key]
            );
        }


             title.textContent =
        return api.get({
                 '同じ販売商品が複数回選択されています。';
            action: 'cargoquery',
            format: 'json',
            tables: 'StallMenuItems',
fields:
    'menu_item_id=menu_item_id,' +
    'stall_id=stall_id,' +
    'name=name,' +
    'status=status',
             where:
                'stall_id=' +
                Number(stallId) +
                 " AND status='active'",
            order_by:
    'menu_item_id',
            limit: 100
        }).then(function (res) {


             warning.appendChild(title);
             const rows =
                cargoRows(res);


             const detail =
             menuCache[key] =
                 document.createElement('div');
                 rows;


             detail.textContent =
             return rows;
                duplicates.join('') +
        });
                ' が重複しています。' +
    }
                 '重複登録でないか確認してください。' +
 
                '保存自体は禁止しません。';
    function optionBelongsToMenu(
        option,
        menu
    ) {
 
        const name =
            String(menu.name || '');
 
        const id =
            String(
                 menu.menu_item_id || ''
            );


             warning.appendChild(detail);
        const value =
             String(option.value || '');


             warning.hidden = false;
        const text =
        }
             String(
                option.textContent || ''
            );


         /*
         /*
         * multipleで後から追加された行にも対応。
         * 商品名が一意
         */
         */
         if (
         if (
             form.dataset
             value === name ||
                .offeringDuplicateWarning !== '1'
            text === name
         ) {
         ) {
             form.dataset
             return true;
                .offeringDuplicateWarning = '1';
 
/*
* Page Forms / Select2 は
* jQueryのchangeを使う場合があるため、
* jQuery側でイベント委譲する。
*/
if (window.jQuery) {
    jQuery(form).on(
        'change.offeringDuplicateWarning',
        MENU_SELECTOR,
        function () {
            window.setTimeout(
                checkDuplicates,
                0
            );
         }
         }
    );
} else {
    /*
    * jQueryが無い場合のフォールバック。
    */
    form.addEventListener(
        'change',
        function (event) {
            if (
                event.target.matches &&
                event.target.matches(
                    MENU_SELECTOR
                )
            ) {
                window.setTimeout(
                    checkDuplicates,
                    0
                );
            }
        }
    );
}


            const observer =
        /*
                new MutationObserver(
        * Page Formsによる
                    function () {
        * 同名商品の識別表示
                        window.setTimeout(
        *
                            checkDuplicates,
        * たこ焼き (1)
                            0
        * たこ焼き (3)
                        );
        */
                    }
        const mapped =
                );
            name + ' (' + id + ')';


            observer.observe(
        return (
                form,
             value === mapped ||
                {
             text === mapped
                    childList: true,
                    subtree: true
                }
             );
        }
 
        checkDuplicates();
    }
 
    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
             'DOMContentLoaded',
            setupOfferingDuplicateWarning
         );
         );
    } else {
        setupOfferingDuplicateWarning();
     }
     }


     mw.hook(
     function makeOptions(menus) {
        'wikipage.content'
    ).add(
        setupOfferingDuplicateWarning
    );


})();
         const options = [];
 
/*
* StallMenuItem
* 入力検証・状態日本語化
*/
(function () {
    'use strict';
 
    function setupStallMenuItemValidation() {
         const form = document.getElementById('pfForm');
 
        if (!form) {
            return;
        }


         /*
         /*
         * StallMenuItemフォーム以外では何もしない。
         * 空欄
         */
         */
         const nameInput = form.querySelector(
         const blank =
            'input[name="StallMenuItem[name]"]'
            masterOptions.find(
        );
                function (option) {
                    return (
                        option.value === ''
                    );
                }
            );


         if (!nameInput) {
         if (blank) {
             return;
             options.push(
                blank.cloneNode(true)
            );
        } else {
            options.push(
                new Option('', '')
            );
         }
         }


         /*
         menus.forEach(
        * 二重初期化防止
            function (menu) {
        */
 
        if (
                const option =
            form.dataset.stallMenuItemValidationInitialized === '1'
                    masterOptions.find(
        ) {
                        function (candidate) {
            return;
                            return optionBelongsToMenu(
        }
                                candidate,
                                menu
                            );
                        }
                    );


        form.dataset.stallMenuItemValidationInitialized = '1';
                if (option) {
                    options.push(
                        option.cloneNode(true)
                    );
                } else {
                    console.warn(
                        'Page Formsのoptionを特定できません:',
                        menu
                    );
                }
            }
        );


         /*
         return options;
        * =====================================
    }
        * 状態を日本語表示
        * =====================================
        */
        const statusLabels = {
            active: '取扱中',
            inactive: '一時停止',
            discontinued: '取扱終了',
            unknown: '未確認'
        };


        const statusSelect = form.querySelector(
    function optionSignature(select) {
            'select[name="StallMenuItem[status]"]'
        );


         if (statusSelect) {
         return [...select.options]
            Array.from(statusSelect.options).forEach(
            .map(function (option) {
                function (option) {
                return (
                     if (statusLabels[option.value]) {
                     option.value +
                        option.textContent =
                    '::' +
                            statusLabels[option.value];
                    option.textContent
                    }
                 );
                 }
            })
            );
            .join('||');
        }
 
 
        console.log(
            '商品マスター入力チェックを初期化しました。'
        );
     }
     }


     if (document.readyState === 'loading') {
     function filterMenuSelects(
         document.addEventListener(
         menus,
            'DOMContentLoaded',
         clearSelection
            setupStallMenuItemValidation
     ) {
         );
     } else {
        setupStallMenuItemValidation();
    }


    mw.hook('wikipage.content').add(
         const desiredTemplate =
         setupStallMenuItemValidation
            makeOptions(menus);
    );


})();
        const desiredSignature =
            desiredTemplate
                .map(function (option) {
                    return (
                        option.value +
                        '::' +
                        option.textContent
                    );
                })
                .join('||');


/*
        applying = true;
* StallMenuItem
* 同一屋台 + 同一商品名の重複警告
*
* 保存は禁止しない。
*/
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';


    const api = new mw.Api();
        getRealMenuSelects().forEach(
            function (select) {


    function cargoQuote(value) {
                const previousValue =
        return "'" + String(value)
                    select.value;
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }


    function cargoRows(response) {
                /*
        return (response.cargoquery || []).map(
                * すでに正しい候補なら
            function (row) {
                * DOMを触らない
                return row.title || row;
                */
            }
                if (
        );
                    optionSignature(select) ===
    }
                    desiredSignature
                ) {
                    if (clearSelection &&
                        select.value !== '') {
 
                        select.value = '';


    function cargoQuery(
                        if (window.jQuery) {
        tables,
                            jQuery(select)
        fields,
                                .trigger('change');
        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;
        return String(value || '')
                }
            .replace(/_/g, ' ')
            .trim();
    }


    function setupStallMenuItemDuplicateWarning() {
                const newOptions =
        const form =
                    desiredTemplate.map(
            document.getElementById('pfForm');
                        function (option) {
                            return option
                                .cloneNode(true);
                        }
                    );


        if (!form) {
                select.replaceChildren(
            return;
                    ...newOptions
        }
                );


        /*
                if (!clearSelection) {
        * StallMenuItemフォームだけを対象にする。
        */
        const stallSelect = form.querySelector(
            'select[name="StallMenuItem[stall_id]"]'
        );


        const nameInput = form.querySelector(
                    const exists =
            'input[name="StallMenuItem[name]"]'
                        [...select.options]
        );
                            .some(
                                function (option) {
                                    return (
                                        option.value ===
                                        previousValue
                                    );
                                }
                            );


        if (!stallSelect || !nameInput) {
                    if (exists) {
            return;
                        select.value =
        }
                            previousValue;
                    }
                }


        /*
                if (clearSelection) {
        * 二重初期化防止
                    select.value = '';
        */
                }
        if (
            form.dataset
                .stallMenuItemDuplicateWarning ===
            '1'
        ) {
            return;
        }


        form.dataset
                if (window.jQuery) {
            .stallMenuItemDuplicateWarning =
                    jQuery(select)
             '1';
                        .trigger('change');
                }
             }
        );


         /*
         /*
         * 警告表示欄
         * MutationObserverに
        * 自分自身の変更を拾わせない
         */
         */
         const warning =
         setTimeout(
             document.createElement('div');
             function () {
 
                applying = false;
        warning.className =
             },
            'stall-menu-item-duplicate-warning';
             0
 
        warning.setAttribute(
             'role',
             'status'
         );
         );
    }


         warning.hidden = true;
    function refreshMenus(
         clearSelection
    ) {


         warning.style.marginTop = '8px';
         const stall =
        warning.style.padding = '10px';
            document.querySelector(
        warning.style.border = '1px solid #a2a9b1';
                STALL_SELECTOR
        warning.style.borderRadius = '4px';
            );
 
if (!stall) {
    return;
}
 
if (!stall.value) {
    /*
    * 屋台が未選択なら、
    * 進行中の古い非同期処理を無効化し、
    * 商品候補を空欄だけに戻す。
    */
    ++requestSerial;


         const container =
    filterMenuSelects(
            nameInput.closest('td') ||
        [],
            nameInput.parentNode;
         true
    );


        container.appendChild(warning);
    return;
}


         let timer = null;
         const serial =
        let requestSerial = 0;
            ++requestSerial;


         /*
         const stallName =
        * Page Formsのmappingでは
            stall.value;
        * SELECT.valueが屋台名になる場合があるため、
        * Cargoからstall_idを解決する。
        */
        function resolveStallId() {
            const rawValue =
                String(
                    stallSelect.value || ''
                ).trim();


             if (!rawValue) {
        resolveStallId(
                return Promise.resolve('');
             stallName
            }
        )
        .then(function (stallId) {


             /*
             if (
            * 数値ならそのまま使用。
                serial !==
            */
                requestSerial
             if (/^\d+$/.test(rawValue)) {
             ) {
                 return Promise.resolve(
                 return null;
                    rawValue
                );
             }
             }


             const selectedOption =
             console.log(
                 stallSelect.options[
                 '[屋台→商品V2]',
                    stallSelect.selectedIndex
                stallName,
                 ];
                 '→ stall_id=' +
 
                 stallId
            const selectedText =
            );
                 selectedOption
                    ? selectedOption.textContent.trim()
                    : '';


             const names = [];
             return loadMenus(
                stallId
            );


            if (rawValue) {
        })
                names.push(rawValue);
        .then(function (menus) {
            }


             if (
             if (
                 selectedText &&
                 !menus ||
                 names.indexOf(selectedText) === -1
                 serial !==
                    requestSerial
             ) {
             ) {
                 names.push(selectedText);
                 return;
             }
             }


             if (names.length === 0) {
             console.log(
                 return Promise.resolve('');
                 '[販売商品候補V2]',
            }
                menus
            );


             const where = names.map(
             filterMenuSelects(
                 function (name) {
                 menus,
                    return (
                 clearSelection
                        'name=' +
             );
                        cargoQuote(name)
                    );
                 }
             ).join(' OR ');


            return cargoQuery(
        })
                'Stalls',
        .catch(function (err) {
                'stall_id=stall_id,' +
                    'name=stall_name',
                where,
                10
            ).then(
                function (rows) {
                    if (!rows.length) {
                        return '';
                    }


                    return String(
            console.error(
                        rows[0].stall_id || ''
                '[屋台→商品V2] エラー:',
                    );
                 err
                 }
             );
             );
         }
         });
    }


        function clearWarning() {
    /*
            warning.hidden = true;
    * Page Formsによる
            warning.textContent = '';
    * option再生成を検出
        }
    */
    function mutationTouchesMenus(
        mutation
    ) {


         function showWarning(rows) {
         const target =
             warning.textContent = '';
             mutation.target;


             const title =
        if (
                document.createElement('strong');
             target.nodeType === 1 &&
            target.matches &&
            target.matches(MENU_SELECTOR)
        ) {
            return true;
        }


             title.textContent =
        for (
                '同じ屋台に同名の商品がすでに登録されています。';
            const node of
             mutation.addedNodes
        ) {


             warning.appendChild(title);
             if (
                node.nodeType !== 1
            ) {
                continue;
            }


             const text =
             if (
                 document.createElement('div');
                 node.matches &&
                node.matches(MENU_SELECTOR)
            ) {
                return true;
            }


             text.textContent =
             if (
                 '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
                node.querySelector &&
                node.querySelector(
                    MENU_SELECTOR
                 )
            ) {
                return true;
            }


             warning.appendChild(text);
             /*
            * SELECTの中にOPTIONが追加された
            */
            if (
                node.tagName === 'OPTION' &&
                node.parentElement &&
                node.parentElement.matches &&
                node.parentElement.matches(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }
        }


            const list =
        return false;
                document.createElement('ul');
    }


            rows.forEach(
    const observer =
                function (row) {
        new MutationObserver(
                    const item =
            function (mutations) {
                        document.createElement('li');


                    const link =
                if (applying) {
                        document.createElement('a');
                    return;
                }


                    link.href =
                const touched =
                        mw.util.getUrl(
                    mutations.some(
                            row.page_name
                        mutationTouchesMenus
                        );
                    );


                    link.textContent =
                if (!touched) {
                        (
                     return;
                            row.menu_name ||
                            '商品'
                        ) +
                        '(商品ID: ' +
                        row.menu_item_id +
                        ')';
 
                    link.target = '_blank';
 
                    item.appendChild(link);
                     list.appendChild(item);
                 }
                 }
            );


            warning.appendChild(list);
                clearTimeout(
            warning.hidden = false;
                    observerTimer
        }
                );


        function checkDuplicate() {
                /*
            const menuName =
                * Page Formsの再初期化が
                nameInput.value.trim();
                * 完了してから実行
 
                */
            if (
                 observerTimer =
                 !stallSelect.value ||
                    setTimeout(
                !menuName
                        function () {
            ) {
                            refreshMenus(false);
                clearWarning();
                        },
                return;
                        250
                    );
             }
             }
        );


            const currentRequest =
    const form =
                ++requestSerial;
        document.getElementById(
            'pfForm'
        ) || document.body;


            resolveStallId().then(
    observer.observe(
                function (stallId) {
        form,
                    if (
        {
                        currentRequest !==
            childList: true,
                        requestSerial
            subtree: true
                    ) {
        }
                        return null;
    );
                    }
 
    /*
    * 屋台変更
    */
    const stall =
        document.querySelector(
            STALL_SELECTOR
        );


                    if (!stallId) {
    function onStallChange() {
                        clearWarning();
        refreshMenus(true);
                        return null;
    }
                    }


                    return cargoQuery(
        stall.addEventListener(
                        'StallMenuItems',
        'change',
                        'menu_item_id=menu_item_id,' +
        onStallChange
                            '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
    */
                    ) {
    refreshMenus(false);
                        return;
                    }


                    /*
        console.log(
                    * 編集画面では
        '屋台→販売商品連動を初期化しました。'
                    * 自分自身を重複候補から除外。
    );
                    */
                    const currentPage =
                        normalizePageName(
                            mw.config.get(
                                'wgPageName'
                            )
                        );


                    const duplicates =
});
                        rows.filter(
                            function (row) {
                                return (
                                    normalizePageName(
                                        row.page_name
                                    ) !==
                                    currentPage
                                );
                            }
                        );


                    if (
/*
                        duplicates.length === 0
* FestivalStallMenuOffering
                    ) {
* 同一Placement内の商品重複警告
                        clearWarning();
*
                        return;
* 保存は禁止しない。
                    }
*/
(function () {
    'use strict';


                    showWarning(
    const MENU_SELECTOR =
                        duplicates
        'select[name^="FestivalStallMenuOffering["]' +
                    );
        '[name$="[menu_item_id]"]';
                }
            ).catch(
                function (error) {
                    console.error(
                        '商品重複確認に失敗しました。',
                        error
                    );


                    clearWarning();
    function setupOfferingDuplicateWarning() {
                }
        const form =
             );
             document.getElementById('pfForm');
        }


         function scheduleCheck() {
         if (!form) {
             window.clearTimeout(timer);
             return;
 
            timer =
                window.setTimeout(
                    checkDuplicate,
                    300
                );
         }
         }


         stallSelect.addEventListener(
         /*
            'change',
        * FestivalStallPlacementフォームだけを対象にする。
             scheduleCheck
        */
         );
        if (
            !form.querySelector(
                '[name="FestivalStallPlacement[stall_id]"]'
             )
         ) {
            return;
        }


         nameInput.addEventListener(
         function getMenuSelects() {
             'input',
            return [
             scheduleCheck
                ...form.querySelectorAll(
         );
                    MENU_SELECTOR
                )
             ].filter(function (select) {
                return !select.name.includes('[num]');
             });
         }


         nameInput.addEventListener(
         function getWarning() {
            'change',
            let warning =
            scheduleCheck
                form.querySelector(
        );
                    '.stall-offering-duplicate-warning'
                );


        /*
            if (warning) {
        * 編集画面で既存値が入っている場合にも確認。
                return warning;
        */
            }
        scheduleCheck();


        console.log(
             const firstSelect =
             '商品重複警告を初期化しました。'
                getMenuSelects()[0];
        );
    }


    if (
            if (!firstSelect) {
        document.readyState ===
                return null;
        'loading'
             }
    ) {
        document.addEventListener(
            'DOMContentLoaded',
             setupStallMenuItemDuplicateWarning
        );
    } else {
        setupStallMenuItemDuplicateWarning();
    }


    mw.hook(
            warning =
        'wikipage.content'
                document.createElement('div');
    ).add(
        setupStallMenuItemDuplicateWarning
    );


});
            warning.className =
                'stall-offering-duplicate-warning';


/* =========================================
            warning.setAttribute(
  * Venue:緯度・経度バリデーション
                'role',
  * ========================================= */
                'status'
$(function () {
            );
     const latitudeInput = document.querySelector(
 
         'input[name="Venue[latitude]"]'
            warning.hidden = true;
 
            warning.style.marginTop = '8px';
            warning.style.padding = '10px';
            warning.style.border =
                '1px solid #a2a9b1';
            warning.style.borderRadius = '4px';
 
/*
* 警告は個別の商品行ではなく、
  * 販売商品multiple全体の上部に表示する。
  */
const wrapper =
    firstSelect.closest(
        '.multipleTemplateWrapper'
    );
 
const list =
    wrapper
        ? wrapper.querySelector(
            '.multipleTemplateList'
        )
        : null;
 
if (list) {
     list.insertAdjacentElement(
         'beforebegin',
        warning
     );
     );
} else {
    const container =
        firstSelect.closest('fieldset') ||
        firstSelect.closest('td') ||
        firstSelect.parentNode;


     const longitudeInput = document.querySelector(
     container.insertBefore(
         'input[name="Venue[longitude]"]'
         warning,
        container.firstChild
     );
     );
}


    function setupVenueCoordinateValidation(
             return warning;
        input,
        label,
        min,
        max
    ) {
        if (!input) {
             return;
         }
         }


         input.inputMode = 'decimal';
         function clearWarning() {
            const warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );


        const validateCoordinate = function () {
            if (!warning) {
             const value = input.value.trim();
                return;
             }


             input.setCustomValidity('');
             warning.hidden = true;
            warning.textContent = '';
        }
 
        function checkDuplicates() {
            const selects =
                getMenuSelects();
 
            const counts = {};
 
            selects.forEach(function (select) {
                const value =
                    String(
                        select.value || ''
                    ).trim();


            /*
                if (!value) {
            * Venueでは緯度・経度は任意。
                    return;
            * 空欄なら正常。
                }
            */
 
            if (value === '') {
                counts[value] =
                return;
                    (counts[value] || 0) + 1;
             }
             });


             /*
             const duplicates =
            * 数値形式チェック
                Object.keys(counts).filter(
            */
                    function (value) {
            if (!/^-?\d+(\.\d+)?$/.test(value)) {
                        return counts[value] > 1;
                input.setCustomValidity(
                     }
                     label + 'は数値で入力してください。'
                 );
                 );
            if (duplicates.length === 0) {
                clearWarning();
                 return;
                 return;
             }
             }


            /*
             const warning =
            * 日本付近の範囲チェック
                getWarning();
            */
             const number = Number(value);


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


        input.addEventListener(
            warning.textContent = '';
            'input',
            validateCoordinate
        );


        input.addEventListener(
            const title =
            'change',
                document.createElement('strong');
            validateCoordinate
        );


        input.addEventListener(
            title.textContent =
            'invalid',
                '同じ販売商品が複数回選択されています。';
            validateCoordinate
        );


        validateCoordinate();
            warning.appendChild(title);
    }


    setupVenueCoordinateValidation(
            const detail =
        latitudeInput,
                document.createElement('div');
        '緯度',
        20,
        46
    );


    setupVenueCoordinateValidation(
            detail.textContent =
        longitudeInput,
                duplicates.join('、') +
        '経度',
                ' が重複しています。' +
        122,
                '重複登録でないか確認してください。' +
        154
                '保存自体は禁止しません。';
    );
});


/* =========================================
            warning.appendChild(detail);
* Venue:地図ピン → 緯度・経度
* ========================================= */
$(function () {
    const latInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );


    const lonInput = document.querySelector(
            warning.hidden = false;
         'input[name="Venue[longitude]"]'
         }
    );


    if (!latInput || !lonInput) {
         /*
         return;
        * multipleで後から追加された行にも対応。
    }
        */
 
    mw.loader.using('ext.pageforms.leaflet').then(function () {
         if (
         if (
             document.getElementById(
             form.dataset
                 'matsuri-venue-location-map'
                 .offeringDuplicateWarning !== '1'
            )
         ) {
         ) {
             return;
             form.dataset
        }
                .offeringDuplicateWarning = '1';


        const mapDiv = document.createElement('div');
/*
         mapDiv.id = 'matsuri-venue-location-map';
* Page Forms / Select2 は
         mapDiv.style.height = '400px';
* jQueryのchangeを使う場合があるため、
         mapDiv.style.width = '100%';
* jQuery側でイベント委譲する。
        mapDiv.style.marginBottom = '8px';
*/
 
if (window.jQuery) {
        const help = document.createElement('div');
    jQuery(form).on(
         help.textContent =
        'change.offeringDuplicateWarning',
            '地図をクリックして会場位置を指定してください。ピンはドラッグして微調整できます。';
        MENU_SELECTOR,
        help.style.marginBottom = '8px';
        function () {
            window.setTimeout(
                checkDuplicates,
                0
            );
         }
    );
} else {
    /*
    * jQueryが無い場合のフォールバック。
    */
    form.addEventListener(
         'change',
         function (event) {
            if (
                event.target.matches &&
                event.target.matches(
                    MENU_SELECTOR
                )
            ) {
                window.setTimeout(
                    checkDuplicates,
                    0
                );
            }
         }
    );
}


        const wrapper = document.createElement('div');
            const observer =
        wrapper.appendChild(help);
                new MutationObserver(
        wrapper.appendChild(mapDiv);
                    function () {
                        window.setTimeout(
                            checkDuplicates,
                            0
                        );
                    }
                );


        const latRow = latInput.closest('tr');
            observer.observe(
 
                form,
        if (!latRow || !latRow.parentNode) {
                {
             return;
                    childList: true,
                    subtree: true
                }
             );
         }
         }


         const mapRow = document.createElement('tr');
         checkDuplicates();
    }


         const th = document.createElement('th');
    if (
         th.textContent = '会場位置を地図から選択';
         document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingDuplicateWarning
        );
    } else {
         setupOfferingDuplicateWarning();
    }


        const td = document.createElement('td');
    mw.hook(
        td.appendChild(wrapper);
        'wikipage.content'
    ).add(
        setupOfferingDuplicateWarning
    );
 
})();


        mapRow.appendChild(th);
/*
        mapRow.appendChild(td);
* StallMenuItem
* 入力検証・状態日本語化
*/
(function () {
    'use strict';


         latRow.parentNode.insertBefore(
    function setupStallMenuItemValidation() {
            mapRow,
         const form = document.getElementById('pfForm');
            latRow
        );


         const hasCoordinates =
         if (!form) {
            latInput.value.trim() !== '' &&
             return;
            lonInput.value.trim() !== '' &&
        }
             !Number.isNaN(Number(latInput.value)) &&
            !Number.isNaN(Number(lonInput.value));


         /*
         /*
         * 既存座標があればそこを表示。
         * StallMenuItemフォーム以外では何もしない。
        * 新規・座標未登録なら日本全体を表示。
         */
         */
         const initialLat = hasCoordinates
         const nameInput = form.querySelector(
             ? Number(latInput.value)
             'input[name="StallMenuItem[name]"]'
            : 36.2048;
        );


         const initialLon = hasCoordinates
         if (!nameInput) {
            ? Number(lonInput.value)
             return;
             : 138.2529;
        }


         const map = L.map(mapDiv).setView(
         /*
            [initialLat, initialLon],
        * 二重初期化防止
             hasCoordinates ? 17 : 5
        */
         );
        if (
            form.dataset.stallMenuItemValidationInitialized === '1'
        ) {
             return;
         }


         L.tileLayer(
         form.dataset.stallMenuItemValidationInitialized = '1';
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);


         let marker = null;
         /*
 
        * =====================================
         function updateInputs(lat, lon) {
        * 状態を日本語表示
             const latValue =
        * =====================================
                Number(lat).toFixed(6);
        */
         const statusLabels = {
            active: '取扱中',
            inactive: '一時停止',
            discontinued: '取扱終了',
             unknown: '未確認'
        };


            const lonValue =
        const statusSelect = form.querySelector(
                Number(lon).toFixed(6);
            'select[name="StallMenuItem[status]"]'
        );


             latInput.value = latValue;
        if (statusSelect) {
            lonInput.value = lonValue;
             Array.from(statusSelect.options).forEach(
 
                 function (option) {
            latInput.dispatchEvent(
                     if (statusLabels[option.value]) {
                 new Event(
                        option.textContent =
                     'input',
                            statusLabels[option.value];
                     { bubbles: true }
                     }
                 )
                 }
             );
             );
        }


            lonInput.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );


            latInput.dispatchEvent(
        console.log(
                new Event(
            '商品マスター入力チェックを初期化しました。'
                    'change',
        );
                    { bubbles: true }
    }
                )
            );


            lonInput.dispatchEvent(
    if (document.readyState === 'loading') {
                new Event(
        document.addEventListener(
                    'change',
            'DOMContentLoaded',
                    { bubbles: true }
            setupStallMenuItemValidation
                )
        );
            );
    } else {
        }
        setupStallMenuItemValidation();
    }


        function placeMarker(latlng) {
    mw.hook('wikipage.content').add(
            if (marker) {
        setupStallMenuItemValidation
                marker.setLatLng(latlng);
    );
            } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true
                    }
                ).addTo(map);


                marker.on(
})();
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();


                        updateInputs(
/*
                            position.lat,
* StallMenuItem
                            position.lng
* 同一屋台 + 同一商品名の重複警告
                        );
*
                    }
* 保存は禁止しない。
                );
*/
            }
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';


            updateInputs(
    const api = new mw.Api();
                latlng.lat,
                latlng.lng
            );
        }


         if (hasCoordinates) {
    function cargoQuote(value) {
             placeMarker({
         return "'" + String(value)
                lat: initialLat,
             .replace(/\\/g, '\\\\')
                lng: initialLon
             .replace(/'/g, "\\'") + "'";
             });
    }
        }


         map.on(
    function cargoRows(response) {
            'click',
         return (response.cargoquery || []).map(
             function (event) {
             function (row) {
                 placeMarker(
                 return row.title || row;
                    event.latlng
                );
             }
             }
         );
         );
    }


         /*
    function cargoQuery(
        * 緯度・経度を手動修正した場合も
         tables,
        * ピンを同期する。
        fields,
        */
        where,
         function syncMarkerFromInputs() {
        limit
             const lat =
    ) {
                Number(latInput.value);
         return api.get({
 
             action: 'cargoquery',
             const lon =
            format: 'json',
                Number(lonInput.value);
            tables: tables,
 
             fields: fields,
             if (
            where: where,
                latInput.value.trim() === '' ||
             limit: String(limit || 20)
                lonInput.value.trim() === '' ||
        }).then(
                Number.isNaN(lat) ||
             function (response) {
                Number.isNaN(lon)
                 return cargoRows(response);
             ) {
                 return;
             }
             }
        );
    }


            const latlng = {
    function normalizePageName(value) {
                lat: lat,
        return String(value || '')
                lng: lon
            .replace(/_/g, ' ')
            };
            .trim();
    }


            if (marker) {
    function setupStallMenuItemDuplicateWarning() {
                marker.setLatLng(latlng);
        const form =
             } else {
             document.getElementById('pfForm');
                marker = L.marker(
                    latlng,
                    {
                        draggable: true
                    }
                ).addTo(map);


                marker.on(
        if (!form) {
                    'dragend',
            return;
                    function () {
        }
                        const position =
                            marker.getLatLng();


                        updateInputs(
        /*
                            position.lat,
        * StallMenuItemフォームだけを対象にする。
                            position.lng
        */
                        );
        const stallSelect = form.querySelector(
                    }
            'select[name="StallMenuItem[stall_id]"]'
                );
        );
            }


            map.setView(
        const nameInput = form.querySelector(
                [lat, lon],
            'input[name="StallMenuItem[name]"]'
                17
        );
            );
        }


        latInput.addEventListener(
         if (!stallSelect || !nameInput) {
            'change',
            syncMarkerFromInputs
        );
 
        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );
 
        setTimeout(function () {
            map.invalidateSize();
        }, 100);
 
        console.log(
            'Venue地図ピン入力を初期化しました。'
        );
    });
});
 
/* =========================================
* FestivalStallPlacement:
* 祭り → 会場候補連動
* ========================================= */
$(function () {
    function setupFestivalVenueFilter() {
        const festivalSelect =
            document.querySelector(
                'select[name="FestivalStallPlacement[festival_id]"]'
            );
 
        const venueSelect =
            document.querySelector(
                'select[name="FestivalStallPlacement[venue_id]"]'
            );
 
         if (!festivalSelect || !venueSelect) {
             return;
             return;
         }
         }


        /*
        * 二重初期化防止
        */
         if (
         if (
             venueSelect.dataset.r5FestivalVenueFilter ===
             form.dataset
                .stallMenuItemDuplicateWarning ===
             '1'
             '1'
         ) {
         ) {
10,730行目: 10,471行目:
         }
         }


         venueSelect.dataset.r5FestivalVenueFilter =
         form.dataset
            .stallMenuItemDuplicateWarning =
             '1';
             '1';


         const api = new mw.Api();
        /*
        * 警告表示欄
        */
         const warning =
            document.createElement('div');


         const originalOptions =
         warning.className =
             Array.from(
             'stall-menu-item-duplicate-warning';
                venueSelect.options
            ).map(function (option) {
                return option.cloneNode(true);
            });


         const initialFestival =
         warning.setAttribute(
             festivalSelect.value.trim();
            'role',
             'status'
        );


         const initialVenue =
         warning.hidden = true;
            venueSelect.value.trim();


         let requestId = 0;
         warning.style.marginTop = '8px';
        warning.style.padding = '10px';
        warning.style.border = '1px solid #a2a9b1';
        warning.style.borderRadius = '4px';


         function escapeCargoValue(value) {
         const container =
             return String(value).replace(
             nameInput.closest('td') ||
                /'/g,
             nameInput.parentNode;
                "''"
             );
        }


         function getBlankOption(label) {
         container.appendChild(warning);
            let blank =
                originalOptions.find(function (option) {
                    return option.value === '';
                });


            if (blank) {
        let timer = null;
                blank=blank.cloneNode(true);
        let requestSerial = 0;
            } else {
                blank=document.createElement(
                    'option'
                );
                blank.value='';
            }


             blank.textContent=label;
        /*
        * Page Formsのmappingでは
        * SELECT.valueが屋台名になる場合があるため、
        * Cargoからstall_idを解決する。
        */
        function resolveStallId() {
             const rawValue =
                String(
                    stallSelect.value || ''
                ).trim();


             return blank;
             if (!rawValue) {
        }
                return Promise.resolve('');
            }


        function findOriginalOption(value) {
            /*
             const option =
            * 数値ならそのまま使用。
                originalOptions.find(
            */
                    function (item) {
             if (/^\d+$/.test(rawValue)) {
                        return item.value === value;
                return Promise.resolve(
                     }
                     rawValue
                 );
                 );
            }


             return option
             const selectedOption =
                 ? option.cloneNode(true)
                 stallSelect.options[
                 : null;
                    stallSelect.selectedIndex
        }
                 ];


        function replaceOptions(
             const selectedText =
            venuePages,
                 selectedOption
            preserveCurrent
                     ? selectedOption.textContent.trim()
        ) {
             const oldValue =
                 preserveCurrent
                     ? initialVenue
                     : '';
                     : '';


             const fragment =
             const names = [];
                document.createDocumentFragment();


             fragment.appendChild(
             if (rawValue) {
                getBlankOption('未指定')
                 names.push(rawValue);
            );
             }
 
            venuePages.forEach(function (page) {
                let option =
                    findOriginalOption(page);
 
                if (!option) {
                    console.warn(
                        'Page Formsの元候補に会場がありません。',
                        page
                    );
 
                    return;
                }
 
                option.selected=false;
                 fragment.appendChild(option);
             });


             if (
             if (
                 preserveCurrent &&
                 selectedText &&
                 oldValue !== '' &&
                 names.indexOf(selectedText) === -1
                !venuePages.includes(oldValue)
             ) {
             ) {
                 const currentOption =
                 names.push(selectedText);
                    findOriginalOption(oldValue);
            }


                if (currentOption) {
            if (names.length === 0) {
                    currentOption.textContent +=
                return Promise.resolve('');
                        '(現在登録値)';
            }


                     fragment.appendChild(
            const where = names.map(
                         currentOption
                function (name) {
                     return (
                         'name=' +
                        cargoQuote(name)
                     );
                     );
                 }
                 }
             }
             ).join(' OR ');


             venueSelect.replaceChildren(
             return cargoQuery(
                 fragment
                 'Stalls',
            );
                 'stall_id=stall_id,' +
 
                    'name=stall_name',
            let nextValue='';
                 where,
 
                10
            if (
            ).then(
                 preserveCurrent &&
                function (rows) {
                oldValue !== '' &&
                     if (!rows.length) {
                 Array.from(
                        return '';
                    venueSelect.options
                    }
                ).some(function (option) {
                     return option.value ===
                        oldValue;
                })
            ) {
                nextValue=oldValue;
            }


            venueSelect.value=nextValue;
                    return String(
            venueSelect.disabled=false;
                        rows[0].stall_id || ''
 
                     );
            venueSelect.dispatchEvent(
                 }
                new Event(
                    'change',
                     { bubbles:true }
                 )
             );
             );
         }
         }


         function showLoading() {
         function clearWarning() {
             venueSelect.replaceChildren(
             warning.hidden = true;
                getBlankOption(
             warning.textContent = '';
                    '会場候補を読み込み中…'
                )
            );
 
             venueSelect.disabled=true;
         }
         }


         function showFailure(
         function showWarning(rows) {
            preserveCurrent
             warning.textContent = '';
        ) {
             const fragment =
                document.createDocumentFragment();


             fragment.appendChild(
             const title =
                 getBlankOption(
                 document.createElement('strong');
                    '未指定(候補取得失敗)'
                )
            );


             if (
             title.textContent =
                preserveCurrent &&
                 '同じ屋台に同名の商品がすでに登録されています。';
                 initialVenue !== ''
            ) {
                const current =
                    findOriginalOption(
                        initialVenue
                    );


                if (current) {
            warning.appendChild(title);
                    current.textContent +=
                        '(現在登録値)';


                    current.selected=true;
            const text =
                document.createElement('div');


                    fragment.appendChild(
            text.textContent =
                        current
                '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
                     );
 
                }
            warning.appendChild(text);
            }
 
            const list =
                document.createElement('ul');
 
            rows.forEach(
                function (row) {
                     const item =
                        document.createElement('li');
 
                    const link =
                        document.createElement('a');


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


            venueSelect.disabled=false;
                    link.textContent =
        }
                        (
                            row.menu_name ||
                            '商品'
                        ) +
                        '(商品ID: ' +
                        row.menu_item_id +
                        ')';


        function loadVenues(
                    link.target = '_blank';
            preserveCurrent
        ) {
            const festivalValue =
                festivalSelect.value.trim();


            const currentRequest =
                    item.appendChild(link);
                 ++requestId;
                    list.appendChild(item);
                 }
            );


             if (festivalValue === '') {
             warning.appendChild(list);
                venueSelect.replaceChildren(
            warning.hidden = false;
                    getBlankOption('未指定')
        }
                );


                 venueSelect.disabled=false;
        function checkDuplicate() {
            const menuName =
                 nameInput.value.trim();


            if (
                !stallSelect.value ||
                !menuName
            ) {
                clearWarning();
                 return;
                 return;
             }
             }


            showLoading();
             const currentRequest =
 
                 ++requestSerial;
             const escaped =
                escapeCargoValue(
                    festivalValue
                );
 
            api.get({
                action:'cargoquery',
                format:'json',
                tables:
                    'Festivals=F,' +
                    'FestivalVenues=FV,' +
                    'Venues=V',
                 fields:
                    'V._pageName=venue_page,' +
                    'V.name=venue_name,' +
                    'FV.sort_order=sort_order',
                join_on:
                    'F.festival_id=FV.festival_id,' +
                    'FV.venue_id=V.venue_id',
                where:
                    "(" +
                    "F.name='" +
                    escaped +
                    "' OR " +
                    "F._pageName='" +
                    escaped +
                    "'" +
                    ")",
                order_by:
                    'FV.sort_order ASC,' +
                    'V.name ASC',
                limit:'100'
            }).then(function (data) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }
 
                const rows =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];
 
                const venuePages=[];
 
                rows.forEach(function (result) {
                    const row =
                        result.title ||
                        result;
 
                    const page =
                        row.venue_page ===
                            undefined ||
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();


            resolveStallId().then(
                function (stallId) {
                     if (
                     if (
                         page !== '' &&
                         currentRequest !==
                         !venuePages.includes(page)
                         requestSerial
                     ) {
                     ) {
                         venuePages.push(page);
                         return null;
                     }
                     }
                });


                replaceOptions(
                    if (!stallId) {
                    venuePages,
                        clearWarning();
                     preserveCurrent
                        return null;
                );
                     }


                console.log(
                    return cargoQuery(
                    '祭り連動会場候補を更新しました。',
                        'StallMenuItems',
                    {
                        'menu_item_id=menu_item_id,' +
                         festival:
                            'name=menu_name,' +
                             festivalValue,
                            '_pageName=page_name',
                         venues:
                         'stall_id=' +
                            venuePages
                            stallId +
                            ' AND name=' +
                             cargoQuote(
                                menuName
                            ),
                         20
                    );
                }
            ).then(
                function (rows) {
                    if (
                        rows === null ||
                        rows === undefined
                    ) {
                        return;
                     }
                     }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }


                console.error(
                    if (
                    '祭り連動会場候補の取得に失敗しました。',
                        currentRequest !==
                     error
                        requestSerial
                );
                     ) {
                        return;
                    }


                showFailure(
                    /*
                    preserveCurrent
                    * 編集画面では
                );
                    * 自分自身を重複候補から除外。
            });
                    */
        }
                    const currentPage =
                        normalizePageName(
                            mw.config.get(
                                'wgPageName'
                            )
                        );


        festivalSelect.addEventListener(
                    const duplicates =
            'change',
                        rows.filter(
            function () {
                            function (row) {
                loadVenues(false);
                                return (
            }
                                    normalizePageName(
        );
                                        row.page_name
                                    ) !==
                                    currentPage
                                );
                            }
                        );


        loadVenues(
                    if (
            festivalSelect.value.trim() ===
                        duplicates.length === 0
                initialFestival &&
                    ) {
            initialVenue !== ''
                        clearWarning();
        );
                        return;
    }
                    }


    setupFestivalVenueFilter();
                    showWarning(
                        duplicates
                    );
                }
            ).catch(
                function (error) {
                    console.error(
                        '商品重複確認に失敗しました。',
                        error
                    );


    mw.hook(
                    clearWarning();
        'pf.formSetupAfter'
                }
    ).add(
            );
        setupFestivalVenueFilter
        }
    );
});


        function scheduleCheck() {
            window.clearTimeout(timer);


/* =========================================
            timer =
* FestivalStallPlacement:
                window.setTimeout(
* 会場連動地図ピン → 緯度・経度
                    checkDuplicate,
* ========================================= */
                    300
$(function () {
                );
    const venueSelect = document.querySelector(
         }
         'select[name="FestivalStallPlacement[venue_id]"]'
    );


    const latInput = document.querySelector(
        stallSelect.addEventListener(
        'input[name="FestivalStallPlacement[latitude]"]'
            'change',
    );
            scheduleCheck
        );


    const lonInput = document.querySelector(
        nameInput.addEventListener(
         'input[name="FestivalStallPlacement[longitude]"]'
            'input',
    );
            scheduleCheck
        );
 
         nameInput.addEventListener(
            'change',
            scheduleCheck
        );
 
        /*
        * 編集画面で既存値が入っている場合にも確認。
        */
        scheduleCheck();
 
        console.log(
            '商品重複警告を初期化しました。'
        );
    }


     if (
     if (
         !venueSelect ||
         document.readyState ===
         !latInput ||
         'loading'
        !lonInput
     ) {
     ) {
         return;
         document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemDuplicateWarning
        );
    } else {
        setupStallMenuItemDuplicateWarning();
     }
     }


     mw.loader.using(
     mw.hook(
         'ext.pageforms.leaflet'
         'wikipage.content'
     ).then(function () {
     ).add(
         if (
         setupStallMenuItemDuplicateWarning
            document.getElementById(
    );
                'matsuri-placement-location-map'
            )
        ) {
            return;
        }


        const api = new mw.Api();
});


        const mapDiv =
/* =========================================
            document.createElement('div');
* Venue:緯度・経度バリデーション
* ========================================= */
$(function () {
    const latitudeInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );


        mapDiv.id =
    const longitudeInput = document.querySelector(
            'matsuri-placement-location-map';
        'input[name="Venue[longitude]"]'
    );


         mapDiv.style.height = '400px';
    function setupVenueCoordinateValidation(
         mapDiv.style.width = '100%';
        input,
         mapDiv.style.marginBottom = '8px';
        label,
         min,
        max
    ) {
         if (!input) {
            return;
         }


         const help =
         input.inputMode = 'decimal';
            document.createElement('div');


         help.textContent =
         const validateCoordinate = function () {
             '会場を選択すると会場周辺を表示します。' +
             const value = input.value.trim();
            '地図をクリックして実際の屋台位置を指定してください。' +
            'ピンはドラッグして微調整できます。';


        help.style.marginBottom = '8px';
            input.setCustomValidity('');


        const wrapper =
            /*
             document.createElement('div');
            * Venueでは緯度・経度自体は任意。
            * ただし片方だけの入力は禁止する。
            */
             if (value === '') {
                const otherInput =
                    input === latitudeInput
                        ? longitudeInput
                        : latitudeInput;


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


        const latRow =
                return;
             latInput.closest('tr');
             }


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


        const mapRow =
            /*
            document.createElement('tr');
            * 日本付近の範囲チェック
            */
            const number = Number(value);


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


         th.textContent =
         input.addEventListener(
             '出店位置を地図から選択';
             'input',
            validateCoordinate
        );


         const td =
         input.addEventListener(
             document.createElement('td');
             'change',
            validateCoordinate
        );
 
        input.addEventListener(
            'invalid',
            validateCoordinate
        );
 
        validateCoordinate();


         td.appendChild(wrapper);
         return validateCoordinate;
    }


         mapRow.appendChild(th);
    const validateVenueLatitude =
         mapRow.appendChild(td);
         setupVenueCoordinateValidation(
            latitudeInput,
            '緯度',
            20,
            46
         );


         latRow.parentNode.insertBefore(
    const validateVenueLongitude =
             mapRow,
         setupVenueCoordinateValidation(
             latRow
             longitudeInput,
            '経度',
            122,
             154
         );
         );


        /*
    /*
        * 初期状態は日本全体。
    * 一方の座標を変更した場合、
        *
    * 反対側のペア整合性も再検証する。
        * 既存Placementに座標がある場合は
    */
        * 後でその位置へ移動する。
    if (
        */
        latitudeInput &&
         const map = L.map(
         validateVenueLongitude
            mapDiv
    ) {
         ).setView(
         latitudeInput.addEventListener(
             [ 36.2048, 138.2529 ],
             'input',
             5
             validateVenueLongitude
         );
         );


         L.tileLayer(
         latitudeInput.addEventListener(
             'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
             'change',
             {
             validateVenueLongitude
                maxZoom: 19,
        );
                attribution:
    }
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);


         let marker = null;
    if (
         let venueRequestId = 0;
        longitudeInput &&
        validateVenueLatitude
    ) {
         longitudeInput.addEventListener(
            'input',
            validateVenueLatitude
         );


         function dispatchInputEvents(input) {
         longitudeInput.addEventListener(
             input.dispatchEvent(
             'change',
                new Event(
            validateVenueLatitude
                    'input',
        );
                    { bubbles: true }
    }
                )
});
            );


            input.dispatchEvent(
/* =========================================
                new Event(
* Venue:地図ピン → 緯度・経度
                    'change',
* ========================================= */
                    { bubbles: true }
$(function () {
                )
    const latInput = document.querySelector(
            );
        'input[name="Venue[latitude]"]'
        }
    );
 
    const lonInput = document.querySelector(
        'input[name="Venue[longitude]"]'
    );


        function updateInputs(lat, lon) {
    if (!latInput || !lonInput) {
            latInput.value =
        return;
                Number(lat).toFixed(6);
    }


            lonInput.value =
    mw.loader.using('ext.pageforms.leaflet').then(function () {
                Number(lon).toFixed(6);
            const venueMarkerImagePath =
            mw.config.get('wgExtensionAssetsPath') +
            '/PageForms/libs/foreign/leaflet/images/';


             /*
        const venueMarkerIcon = L.icon({
            * 既存の必須・日本範囲チェックを
             iconUrl:
            * そのまま発火させる。
                venueMarkerImagePath +
            */
                'marker-icon.png',
             dispatchInputEvents(latInput);
            iconRetinaUrl:
             dispatchInputEvents(lonInput);
                venueMarkerImagePath +
                'marker-icon-2x.png',
            shadowUrl:
                venueMarkerImagePath +
                'marker-shadow.png',
            iconSize: [25, 41],
            iconAnchor: [12, 41],
            popupAnchor: [1, -34],
             shadowSize: [41, 41]
        });
        if (
             document.getElementById(
                'matsuri-venue-location-map'
            )
        ) {
            return;
         }
         }


         function createMarker(latlng) {
         const mapDiv = document.createElement('div');
            marker = L.marker(
        mapDiv.id = 'matsuri-venue-location-map';
                latlng,
        mapDiv.style.height = '400px';
                {
        mapDiv.style.width = '100%';
                    draggable: true
        mapDiv.style.marginBottom = '8px';
                }
 
            ).addTo(map);
        const status = document.createElement('div');
        status.id = 'matsuri-venue-location-status';
        status.setAttribute(
            'aria-live',
            'polite'
        );
        status.style.marginBottom = '8px';
        status.style.fontWeight = '600';


            marker.on(
        const help = document.createElement('div');
                'dragend',
        help.textContent =
                function () {
            '地図をクリックして会場位置を指定してください。ピンはドラッグして微調整できます。';
                    const position =
        help.style.marginBottom = '8px';
                        marker.getLatLng();


                    updateInputs(
        const controls = document.createElement('div');
                        position.lat,
        controls.style.marginBottom = '8px';
                        position.lng
                    );
                }
            );
        }


         function placeMarker(latlng) {
         const clearButton =
             if (marker) {
             document.createElement('button');
                marker.setLatLng(latlng);
            } else {
                createMarker(latlng);
            }


             updateInputs(
        clearButton.type = 'button';
                latlng.lat,
        clearButton.id =
                latlng.lng
            'matsuri-venue-location-clear';
             );
 
        clearButton.textContent =
            '位置情報をクリア';
 
        controls.appendChild(
             clearButton
        );
 
        const wrapper = document.createElement('div');
        wrapper.appendChild(status);
        wrapper.appendChild(help);
        wrapper.appendChild(controls);
        wrapper.appendChild(mapDiv);
 
        const latRow = latInput.closest('tr');
 
        if (!latRow || !latRow.parentNode) {
             return;
         }
         }


         function removeMarker() {
         const mapRow = document.createElement('tr');
             if (!marker) {
 
                 return;
        const th = document.createElement('th');
            }
        th.textContent = '会場位置を地図から選択';
 
        const td = document.createElement('td');
        td.appendChild(wrapper);
 
        mapRow.appendChild(th);
        mapRow.appendChild(td);
 
        latRow.parentNode.insertBefore(
            mapRow,
             latRow
        );
 
        function updateLocationUi() {
            const latText =
                 latInput.value.trim();


             map.removeLayer(marker);
             const lonText =
            marker = null;
                lonInput.value.trim();
        }


        function clearCoordinates() {
            if (
            latInput.value = '';
                latText === '' &&
             lonInput.value = '';
                lonText === ''
             ) {
                status.textContent =
                    '位置情報:未登録';


            dispatchInputEvents(latInput);
                clearButton.disabled = true;
            dispatchInputEvents(lonInput);
        }


        function getCurrentCoordinates() {
                return;
             const lat =
             }
                Number(latInput.value);


             const lon =
             clearButton.disabled = false;
                Number(lonInput.value);


             if (
             if (
                 latInput.value.trim() === '' ||
                 latText !== '' &&
                 lonInput.value.trim() === '' ||
                 lonText !== ''
                Number.isNaN(lat) ||
                Number.isNaN(lon)
             ) {
             ) {
                 return null;
                status.textContent =
                    '位置情報:座標あり';
 
                 return;
             }
             }


             return {
             status.textContent =
                 lat: lat,
                 '位置情報:入力不完全';
                lng: lon
            };
         }
         }


         function escapeCargoValue(value) {
         clearButton.addEventListener(
            return String(value)
             'click',
                .replace(
             function () {
                    /'/g,
                    "''"
                );
        }
 
        /*
        * 選択されたVenueの座標へ
        * 地図だけ移動する。
        *
        * Placementのlatitude/longitudeには
        * コピーしない。
        */
        function centerOnVenue() {
            const venuePage =
                venueSelect.value.trim();
 
            if (venuePage === '') {
                return;
            }
 
            const currentRequest =
                ++venueRequestId;
 
             api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Venues',
                fields:
                    'venue_id=venue_id,' +
                    '_pageName=page_name,' +
                    'latitude=latitude,' +
                    'longitude=longitude',
                where:
                    "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
             }).then(function (data) {
                /*
                * 連続して会場を変更した場合、
                * 古いレスポンスを無視する。
                */
                 if (
                 if (
                     currentRequest !==
                     latInput.value.trim() === '' &&
                     venueRequestId
                     lonInput.value.trim() === ''
                 ) {
                 ) {
                    updateLocationUi();
                     return;
                     return;
                 }
                 }


                 const result =
                 if (
                    data &&
                     !window.confirm(
                     Array.isArray(
                         '緯度・経度をクリアします。よろしいですか?'
                         data.cargoquery
                     )
                     )
                        ? data.cargoquery
                 ) {
                        : [];
 
                 if (result.length === 0) {
                    console.warn(
                        '会場情報を取得できませんでした。',
                        venuePage
                    );
                     return;
                     return;
                 }
                 }


                 const row =
                 latInput.value = '';
                    result[0].title ||
                lonInput.value = '';
                    result[0];


                 const lat =
                 latInput.dispatchEvent(
                     Number(row.latitude);
                     new Event(
                        'input',
                        { bubbles: true }
                    )
                );


                 const lon =
                 lonInput.dispatchEvent(
                     Number(row.longitude);
                     new Event(
                        'input',
                        { bubbles: true }
                    )
                );


                 if (
                 latInput.dispatchEvent(
                    row.latitude === undefined ||
                     new Event(
                    row.latitude === null ||
                         'change',
                    String(
                         { bubbles: true }
                        row.latitude
                     )
                     ).trim() === '' ||
                    row.longitude === undefined ||
                    row.longitude === null ||
                    String(
                         row.longitude
                    ).trim() === '' ||
                    Number.isNaN(lat) ||
                    Number.isNaN(lon)
                ) {
                    console.warn(
                        '選択した会場には座標が登録されていません。',
                         venuePage
                     );
                    return;
                }
 
                map.setView(
                    [ lat, lon ],
                    18
                 );
                 );


                 console.log(
                 lonInput.dispatchEvent(
                     '会場位置へ地図を移動しました。',
                     new Event(
                    {
                        'change',
                        venue: venuePage,
                        { bubbles: true }
                        latitude: lat,
                     )
                        longitude: lon
                     }
                );
            }).catch(function (error) {
                console.error(
                    '会場座標の取得に失敗しました。',
                    error
                 );
                 );
            });
        }
       


        /*
                 updateLocationUi();
        * 地図クリック
        */
        map.on(
            'click',
            function (event) {
                 placeMarker(
                    event.latlng
                );
             }
             }
         );
         );


         /*
         latInput.addEventListener(
        * 手入力された場合もピンを同期。
            'input',
        */
            updateLocationUi
         function syncMarkerFromInputs() {
         );
            const coordinates =
                getCurrentCoordinates();


            if (!coordinates) {
        lonInput.addEventListener(
                return;
            'input',
             }
             updateLocationUi
        );


            if (marker) {
        latInput.addEventListener(
                marker.setLatLng(
            'change',
                    coordinates
             updateLocationUi
                );
        );
             } else {
                createMarker(
                    coordinates
                );
            }


            map.setView(
         lonInput.addEventListener(
                [
                    coordinates.lat,
                    coordinates.lng
                ],
                18
            );
         }
 
        latInput.addEventListener(
             'change',
             'change',
             syncMarkerFromInputs
             updateLocationUi
         );
         );


         lonInput.addEventListener(
         updateLocationUi();
             'change',
 
             syncMarkerFromInputs
        const hasCoordinates =
        );
            latInput.value.trim() !== '' &&
             lonInput.value.trim() !== '' &&
             !Number.isNaN(Number(latInput.value)) &&
            !Number.isNaN(Number(lonInput.value));


         /*
         /*
         * 会場を変更した場合。
         * 既存座標があればそこを表示。
         *
         * 新規・座標未登録なら日本全体を表示。
        * 前の会場用の屋台座標を
        * 誤って残さないようクリアする。
         */
         */
         venueSelect.addEventListener(
         const initialLat = hasCoordinates
             'change',
             ? Number(latInput.value)
            function () {
             : 36.2048;
                removeMarker();
                clearCoordinates();
                centerOnVenue();
             }
        );


        /*
         const initialLon = hasCoordinates
        * 編集時:
             ? Number(lonInput.value)
        * 既存Placement座標を優先。
            : 138.2529;
        *
        * 新規時:
        * Venue座標へ地図を移動。
        */
         const initialCoordinates =
             getCurrentCoordinates();


         if (initialCoordinates) {
         const map = L.map(mapDiv).setView(
             createMarker(
             [initialLat, initialLon],
                initialCoordinates
            hasCoordinates ? 17 : 5
            );
        );


            map.setView(
        L.tileLayer(
                [
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
                    initialCoordinates.lat,
            {
                    initialCoordinates.lng
                 maxZoom: 19,
                 ],
                 attribution:
                 18
                    '&copy; OpenStreetMap contributors'
            );
            }
        } else {
        ).addTo(map);
            centerOnVenue();
        }


         setTimeout(
         let marker = null;
            function () {
                map.invalidateSize();
            },
            100
        );


         console.log(
         function updateInputs(lat, lon) {
             '出店位置地図ピン入力を初期化しました。'
             const latValue =
        );
                Number(lat).toFixed(6);
    });
});


/* =========================================
            const lonValue =
* Venue:公式サイトURLの形式チェック
                Number(lon).toFixed(6);
* ========================================= */
$(function () {
    const officialSiteInput = document.querySelector(
        'input[name="Venue[official_site]"]'
    );


    if (!officialSiteInput) {
            latInput.value = latValue;
        return;
            lonInput.value = lonValue;
    }


    officialSiteInput.inputMode = 'url';
            latInput.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );


    const validateVenueOfficialSite = function () {
            lonInput.dispatchEvent(
        const value = officialSiteInput.value.trim();
                new Event(
                    'input',
                    { bubbles: true }
                )
            );


        officialSiteInput.setCustomValidity('');
            latInput.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );


        /*
            lonInput.dispatchEvent(
        * 空欄は許可。
                new Event(
        */
                    'change',
        if (value === '') {
                    { bubbles: true }
             return;
                )
             );
         }
         }


         try {
         function placeMarker(latlng) {
             const url = new URL(value);
             if (marker) {
                marker.setLatLng(latlng);
            } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true,
                        icon: venueMarkerIcon
                    }
                ).addTo(map);
 
                marker.on(
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();


            /*
                        updateInputs(
            * http:// または https:// のみ許可。
                            position.lat,
            */
                            position.lng
            if (
                        );
                url.protocol !== 'http:' &&
                     }
                url.protocol !== 'https:'
            ) {
                officialSiteInput.setCustomValidity(
                     '公式サイトURLは http:// または https:// で始まるURLを入力してください。'
                 );
                 );
             }
             }
        } catch (e) {
 
            officialSiteInput.setCustomValidity(
            updateInputs(
                 '公式サイトURLを正しいURL形式で入力してください。'
                latlng.lat,
                 latlng.lng
             );
             );
         }
         }
    };


    officialSiteInput.addEventListener(
        if (hasCoordinates) {
        'input',
            placeMarker({
        validateVenueOfficialSite
                lat: initialLat,
    );
                lng: initialLon
            });
        }


    officialSiteInput.addEventListener(
        map.on(
        'change',
            'click',
         validateVenueOfficialSite
            function (event) {
    );
                placeMarker(
                    event.latlng
                );
            }
         );


    officialSiteInput.addEventListener(
        /*
         'invalid',
        * 緯度・経度を手動修正した場合も
        validateVenueOfficialSite
        * ピンを同期する。
    );
        */
         function syncMarkerFromInputs() {
            const lat =
                Number(latInput.value);


    validateVenueOfficialSite();
            const lon =
});
                Number(lonInput.value);


/*
            const latText =
* Festival
                latInput.value.trim();
* 公式URLの形式チェック
*
* 空欄は許可。
* 入力された場合は http:// または https:// のURLのみ許可する。
*/
(function () {
    const fields = [
        'official_site',
        'official_x',
        'official_instagram',
        'official_facebook',
        'official_youtube'
    ];


    fields.forEach(function (fieldName) {
            const lonText =
        const input = document.querySelector(
                lonInput.value.trim();
            'input[name="Festival[' + fieldName + ']"]'
        );


        if (!input) {
            /*
            return;
            * 両方空欄になった場合は
        }
            * 地図上のピンも削除する。
            */
            if (
                latText === '' &&
                lonText === ''
            ) {
                if (marker) {
                    map.removeLayer(marker);
                    marker = null;
                }


        input.inputMode = 'url';
                map.setView(
                    [36.2048, 138.2529],
                    5
                );


        const validateFestivalUrl = function () {
                return;
             const value = input.value.trim();
             }


             input.setCustomValidity('');
             /*
 
            * 片方のみ入力、または数値不正の場合は
             if (value === '') {
            * 地図上のピンを勝手に変更しない。
            */
             if (
                latText === '' ||
                lonText === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                 return;
                 return;
             }
             }


             try {
             const latlng = {
                 const url = new URL(value);
                 lat: lat,
                lng: lon
            };


                if (
            if (marker) {
                    url.protocol !== 'http:' &&
                marker.setLatLng(latlng);
                     url.protocol !== 'https:'
            } else {
                 ) {
                marker = L.marker(
                    input.setCustomValidity(
                    latlng,
                        'URLは http:// または https:// で始まるURLを入力してください。'
                    {
                     );
                        draggable: true,
                }
                        icon: venueMarkerIcon
            } catch (e) {
                     }
                input.setCustomValidity(
                 ).addTo(map);
                     '正しいURL形式で入力してください。'
 
                marker.on(
                    'dragend',
                     function () {
                        const position =
                            marker.getLatLng();
 
                        updateInputs(
                            position.lat,
                            position.lng
                        );
                     }
                 );
                 );
             }
             }
        };


         input.addEventListener('input', validateFestivalUrl);
            map.setView(
         input.addEventListener('change', validateFestivalUrl);
                [lat, lon],
         input.addEventListener('invalid', validateFestivalUrl);
                17
            );
         }
 
        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );
 
         lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );
 
        setTimeout(function () {
            map.invalidateSize();
        }, 100);
 
         console.log(
            'Venue地図ピン入力を初期化しました。'
        );
     });
     });
})();
});


/* =========================================
/* =========================================
  * FestivalType:slug形式チェック
  * FestivalStallPlacement:
* 祭り → 会場候補連動
  * ========================================= */
  * ========================================= */
$(function () {
$(function () {
     const slugInput = document.querySelector(
     function setupFestivalVenueFilter() {
        'input[name="FestivalType[slug]"]'
        const festivalSelect =
    );
            document.querySelector(
                'input[type="hidden"][name="FestivalStallPlacement[festival_id]"]'
            ) ||
            document.querySelector(
                'select[name="FestivalStallPlacement[festival_id]"]:not(.pfComboBox)'
            );


    if (!slugInput) {
        const venueSelect =
        return;
            document.querySelector(
    }
                'select[name="FestivalStallPlacement[venue_id]"]'
            );


    slugInput.spellcheck = false;
         if (!festivalSelect || !venueSelect) {
 
    const validateFestivalTypeSlug = function () {
        const value = slugInput.value.trim();
 
        slugInput.setCustomValidity('');
 
        /*
        * 空欄の必須チェックは
        * Page Forms の mandatory に任せる。
        */
         if (value === '') {
             return;
             return;
         }
         }


        /*
        * 英小文字・数字を基本とし、
        * 単語の区切りに半角ハイフンのみ許可する。
        *
        * 先頭・末尾のハイフン、
        * 連続ハイフンは許可しない。
        */
         if (
         if (
             !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
             venueSelect.dataset.r5FestivalVenueFilter ===
            '1'
         ) {
         ) {
             slugInput.setCustomValidity(
             return;
                'slugは英小文字・数字・半角ハイフンで入力してください。ハイフンは単語の区切りにのみ使用できます。'
            );
         }
         }
    };


    slugInput.addEventListener(
        venueSelect.dataset.r5FestivalVenueFilter =
        'input',
            '1';
        validateFestivalTypeSlug
    );


    slugInput.addEventListener(
        const api = new mw.Api();
        'change',
        validateFestivalTypeSlug
    );


    slugInput.addEventListener(
        const originalOptions =
        'invalid',
            Array.from(
        validateFestivalTypeSlug
                venueSelect.options
    );
            ).map(function (option) {
                return option.cloneNode(true);
            });


    validateFestivalTypeSlug();
        const initialFestival =
});
            festivalSelect.value.trim();


/* =========================================
        const initialVenue =
* FestivalType:
            venueSelect.value.trim();
* 上位分類の自己参照・循環参照チェック
*
* Page Forms が select 要素を差し替えても
* 動作するようイベント委譲を使用する。
* ========================================= */
window.matsuriFestivalTypeParentValidationVersion = '20260821-v2';
(function () {
    const currentTypeId = Number(
        mw.config.get('wgArticleId')
    );


    let requestId = 0;
        let requestId = 0;


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


         function getTypeByName(name) {
         function getBlankOption(label) {
        return mw.loader.using(
             let blank =
             'mediawiki.api'
                originalOptions.find(function (option) {
        ).then(function () {
                    return option.value === '';
            const api = new mw.Api();
                });


             return api.get({
             if (blank) {
                 action: 'cargoquery',
                 blank=blank.cloneNode(true);
                format: 'json',
            } else {
                 tables: 'FestivalTypes',
                 blank=document.createElement(
                fields:
                     'option'
                    'type_id=type_id,' +
                 );
                     'name=name,' +
                 blank.value='';
                    'parent_id=parent_id',
             }
                 where:
                    "name='" +
                    escapeCargoValue(name) +
                    "'",
                 limit: '2'
             });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];


             return rows.map(function (item) {
             blank.textContent=label;
                return item.title || item;
            });
        });
    }


    function getTypeById(typeId) {
            return blank;
        return mw.loader.using(
         }
            'mediawiki.api'
         ).then(function () {
            const api = new mw.Api();


            return api.get({
         function findOriginalOption(value) {
                action: 'cargoquery',
             const option =
                format: 'json',
                 originalOptions.find(
                tables: 'FestivalTypes',
                    function (item) {
                fields:
                        return item.value === value;
                    'type_id=type_id,' +
                     }
                    'name=name,' +
                );
                    'parent_id=parent_id',
                where:
                    'type_id=' +
                    Number(typeId),
                limit: '1'
            });
         }).then(function (data) {
             const rows =
                 Array.isArray(data.cargoquery)
                    ? data.cargoquery
                     : [];


             if (rows.length === 0) {
             if (option) {
                 return null;
                 return option.cloneNode(true);
             }
             }


             return rows[0].title || rows[0];
             const dynamicOption =
        });
                document.createElement(
    }
                    'option'
                );


    function validateFestivalTypeParent(
            dynamicOption.value =
        parentSelect
                value;
    ) {
        const thisRequest = ++requestId;


        parentSelect.setCustomValidity('');
            dynamicOption.textContent =
                value;


        const parentName =
             dynamicOption.setAttribute(
             parentSelect.value.trim();
                'data-r14-dynamic-venue-option',
 
                '1'
        const nameInput = document.querySelector(
            );
            'input[name="FestivalType[name]"]'
        );


        const currentName =
             return dynamicOption;
            nameInput
                ? nameInput.value.trim()
                : '';
 
        /*
        * 親なしは正常。
        */
        if (parentName === '') {
             return;
         }
         }


         /*
         function dispatchVenueChange(
        * 分類名が同じならAPIを待たず
             preservePlacementCoordinates
        * 即座に自己参照として拒否する。
        */
        if (
             currentName !== '' &&
            parentName === currentName
         ) {
         ) {
             parentSelect.setCustomValidity(
             venueSelect.dispatchEvent(
                 '自分自身を上位分類に設定することはできません。'
                 new CustomEvent(
                    'change',
                    {
                        bubbles: true,
                        detail: {
                            matsuriPreservePlacementCoordinates:
                                preservePlacementCoordinates === true
                        }
                    }
                )
             );
             );
            return;
         }
         }


         /*
         function replaceOptions(
        * 新規ページはまだtype_idを持たないため、
            venuePages,
        * 既存階層との循環は発生しない。
            preserveCurrent,
        */
            preservePlacementCoordinates
         if (
         ) {
            !Number.isInteger(currentTypeId) ||
             const oldValue =
             currentTypeId <= 0
                preserveCurrent
        ) {
                    ? initialVenue
            return;
                    : '';
        }


        /*
            const fragment =
        * API確認中はフォーム送信を止める。
                document.createDocumentFragment();
        */
        parentSelect.setCustomValidity(
            '上位分類を確認しています。'
        );


        /*
             fragment.appendChild(
        * 非同期処理中に別の選択へ変更された、
                 getBlankOption('未指定')
        * またはPage Formsがselectを差し替えたか確認する。
        */
        function isStaleRequest() {
             return (
                 thisRequest !== requestId ||
                !document.contains(parentSelect)
             );
             );
        }


        /*
            venuePages.forEach(function (page) {
        * 選択した分類から親を順番に辿る。
                 let option =
        *
                    findOriginalOption(page);
        * async / await は使用せず、
 
        * Promise の再帰処理で階層を確認する。
                 if (!option) {
        */
                    console.warn(
        function walkParentChain(
                        'Page Formsの元候補に会場がありません。',
            parentId,
                        page
            visited
                    );
        ) {
 
            if (
                    return;
                 parentId === undefined ||
                }
                parentId === null ||
                 String(parentId).trim() === ''
            ) {
                return Promise.resolve(true);
            }


            const numericParentId =
                option.selected=false;
                 Number(parentId);
                 fragment.appendChild(option);
            });


            /*
            * 現在編集中の分類へ戻れば循環。
            */
             if (
             if (
                 numericParentId ===
                 preserveCurrent &&
                 currentTypeId
                oldValue !== '' &&
                 !venuePages.includes(oldValue)
             ) {
             ) {
                 parentSelect.setCustomValidity(
                 const currentOption =
                     'この上位分類を設定すると分類階層が循環するため選択できません。'
                    findOriginalOption(oldValue);
                );
 
                if (currentOption) {
                     currentOption.textContent +=
                        '(現在登録値)';


                return Promise.resolve(false);
                    fragment.appendChild(
                        currentOption
                    );
                }
             }
             }


             /*
             venueSelect.replaceChildren(
            * 既存データ側ですでに循環している場合。
                fragment
            */
            );
 
            let nextValue='';
 
             if (
             if (
                 visited.has(
                 preserveCurrent &&
                     numericParentId
                oldValue !== '' &&
                 )
                Array.from(
                    venueSelect.options
                ).some(function (option) {
                     return option.value ===
                        oldValue;
                 })
             ) {
             ) {
                 parentSelect.setCustomValidity(
                 nextValue=oldValue;
                    '選択した上位分類の階層に循環があります。'
            }
                );


                return Promise.resolve(false);
            venueSelect.value=nextValue;
             }
             venueSelect.disabled=false;


             visited.add(
             dispatchVenueChange(
                 numericParentId
                 preservePlacementCoordinates
             );
             );
        }


            return getTypeById(
        function showLoading() {
                numericParentId
             venueSelect.replaceChildren(
             ).then(function (row) {
                 getBlankOption(
                 if (isStaleRequest()) {
                     '会場候補を読み込み中…'
                     return false;
                 )
                 }
            );


                if (!row) {
            venueSelect.disabled=true;
                    parentSelect.setCustomValidity(
        }
                        '上位分類の階層情報を確認できませんでした。'
                    );
 
                    return false;
                }


                return walkParentChain(
        function showFailure(
                    row.parent_id,
            preserveCurrent,
                    visited
            preservePlacementCoordinates
                );
        ) {
             });
             const fragment =
        }
                document.createDocumentFragment();


        return getTypeByName(
             fragment.appendChild(
             parentName
                getBlankOption(
        ).then(function (parentRows) {
                    '未指定(候補取得失敗)'
            /*
                )
            * その間に別の選択へ変更された場合は
             );
            * 古い結果を無視する。
            */
             if (isStaleRequest()) {
                return false;
            }


             if (parentRows.length === 0) {
             if (
                 parentSelect.setCustomValidity(
                preserveCurrent &&
                     '選択した上位分類を確認できませんでした。'
                initialVenue !== ''
                );
            ) {
                 const current =
                    findOriginalOption(
                        initialVenue
                     );


                 return false;
                 if (current) {
            }
                    current.textContent +=
                        '(現在登録値)';


            /*
                    current.selected=true;
            * 同名分類が複数ある場合は
            * parent_id を一意に決められない。
            */
            if (parentRows.length > 1) {
                parentSelect.setCustomValidity(
                    '同じ名前の祭り分類が複数存在するため、上位分類を特定できません。'
                );


                 return false;
                    fragment.appendChild(
                        current
                    );
                 }
             }
             }


             const selectedParent =
             venueSelect.replaceChildren(
                 parentRows[0];
                 fragment
            );
 
            venueSelect.disabled=false;


             const selectedTypeId =
             dispatchVenueChange(
                 Number(
                 preservePlacementCoordinates
                    selectedParent.type_id
            );
                );
        }


             /*
        function loadVenues(
            * IDでも自己参照をチェックする。
             preserveCurrent,
            * 分類名を編集中に変更した場合にも有効。
            preservePlacementCoordinates
            */
        ) {
            if (
            const festivalValue =
                selectedTypeId ===
                festivalSelect.value.trim();
                 currentTypeId
 
             ) {
            const currentRequest =
                 parentSelect.setCustomValidity(
                 ++requestId;
                     '自分自身を上位分類に設定することはできません。'
 
             if (festivalValue === '') {
                 venueSelect.replaceChildren(
                     getBlankOption('未指定')
                 );
                 );


                 return false;
                 venueSelect.disabled=false;
            }


            const visited =
                 dispatchVenueChange(
                 new Set([
                     preservePlacementCoordinates
                     selectedTypeId
                 );
                 ]);


            return walkParentChain(
                return;
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                /*
                * すべて正常。
                */
                parentSelect.setCustomValidity('');
             }
             }


             return isValid;
             showLoading();
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }


             parentSelect.setCustomValidity(
             const escaped =
                 '上位分類を確認できませんでした。'
                escapeCargoValue(
            );
                    festivalValue
                 );


             console.error(
             api.get({
                 '祭り分類の上位分類チェックに失敗しました。',
                 action:'cargoquery',
                 error
                 format:'json',
             );
                tables:
 
                    'Festivals=F,' +
            return false;
                    'FestivalVenues=FV,' +
        });
                    'Venues=V',
    }
                fields:
                    'V._pageName=venue_page,' +
                    'V.name=venue_name,' +
                    'FV.sort_order=sort_order',
                join_on:
                    'F.festival_id=FV.festival_id,' +
                    'FV.venue_id=V.venue_id',
                where:
                    "(" +
                    "F.name='" +
                    escaped +
                    "' OR " +
                    "F._pageName='" +
                    escaped +
                    "'" +
                    ")",
                order_by:
                    'FV.sort_order ASC,' +
                    'V.name ASC',
                limit:'100'
             }).then(function (data) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }


    /*
                const rows =
    * Page Forms が select を差し替えても
                    data &&
    * document 側で変更を拾う。
                    Array.isArray(
    */
                        data.cargoquery
document.addEventListener(
                    )
    'change',
                        ? data.cargoquery
    function (event) {
                        : [];
        const target =
            event.target;


        if (
                 const venuePages=[];
            target &&
            target.matches(
                 'select[name="FestivalType[parent_id]"]'
            )
        ) {
            validateFestivalTypeParent(
                target
            );
        }
    },
    true
);


    /*
                rows.forEach(function (result) {
    * 初期表示時にも現在存在するselectを確認。
                    const row =
    */
                        result.title ||
    function validateCurrentParent() {
                        result;
        const parentSelect =
            document.querySelector(
                'select[name="FestivalType[parent_id]"]'
            );


        if (parentSelect) {
                    const page =
             validateFestivalTypeParent(
                        row.venue_page ===
                 parentSelect
                            undefined ||
             );
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();
 
                    if (
                        page !== '' &&
                        !venuePages.includes(page)
                    ) {
                        venuePages.push(page);
                    }
                });
 
                replaceOptions(
                    venuePages,
                    preserveCurrent,
                    preservePlacementCoordinates
                );
 
                console.log(
                    '祭り連動会場候補を更新しました。',
                    {
                        festival:
                            festivalValue,
                        venues:
                            venuePages
                    }
                );
             }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }
 
                console.error(
                    '祭り連動会場候補の取得に失敗しました。',
                    error
                 );
 
                showFailure(
                    preserveCurrent,
                    preservePlacementCoordinates
                );
             });
         }
         }
    }


    if (
         festivalSelect.addEventListener(
         document.readyState ===
            'change',
        'loading'
            function () {
    ) {
                loadVenues(
        document.addEventListener(
                    false,
            'DOMContentLoaded',
                    false
             validateCurrentParent
                );
             }
         );
         );
    } else {
        validateCurrentParent();
    }


    /*
        loadVenues(
    * Page Formsによる描画後にも再確認する。
            festivalSelect.value.trim() ===
    */
                initialFestival &&
    if (
             initialVenue !== '',
        typeof mw !== 'undefined' &&
             true
        mw.hook
    ) {
        mw.hook(
             'wikipage.content'
        ).add(
             validateCurrentParent
         );
         );
     }
     }
})();


/* =========================================
    var festivalVenueFilterRetryTimer =
* Area:
        null;
* Area ID・上位地域・地域区分の整合性チェック
*
* 既存Areaデータの正式な階層ルール:
*
* prefecture
*  parent_id = 0
*
* city / special_ward / town / village
*  parent = prefecture
*
* ward
*  parent = city
*
* 自己参照・存在しない親・循環参照も拒否する。
*
* MediaWiki 1.43対応のため
* async / await は使用しない。
* ========================================= */


window.matsuriAreaParentValidationVersion = '20260821-v1';
    function startFestivalVenueFilterSetup() {
        var attempts = 0;
        var maxAttempts = 50;


(function () {
        if (
    let requestId = 0;
            !document.querySelector(
                'select[name="FestivalStallPlacement[venue_id]"]'
            )
        ) {
            return;
        }


    function getAreaById(areaId) {
        if (
        return mw.loader.using(
            festivalVenueFilterRetryTimer !==
             'mediawiki.api'
             null
         ).then(function () {
         ) {
             const api = new mw.Api();
             return;
        }


            return api.get({
         function trySetup() {
                action: 'cargoquery',
             var venueSelect;
                format: 'json',
                tables: 'Areas',
                fields:
                    'area_id=area_id,' +
                    'name=name,' +
                    'area_type=area_type,' +
                    'parent_id=parent_id',
                where:
                    'area_id=' +
                    Number(areaId),
                limit: '2'
            });
         }).then(function (data) {
             const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];


             return rows.map(function (item) {
             festivalVenueFilterRetryTimer =
                 return item.title || item;
                 null;
             });
 
        });
             setupFestivalVenueFilter();
    }
 
            venueSelect =
                document.querySelector(
                    'select[name="FestivalStallPlacement[venue_id]"]'
                );


    function getExpectedParentType(areaType) {
            if (
        switch (areaType) {
                venueSelect &&
            case 'prefecture':
                venueSelect.getAttribute(
                 return '';
                    'data-r5-festival-venue-filter'
                 ) === '1'
            ) {
                return;
            }


             case 'city':
             attempts += 1;
            case 'special_ward':
            case 'town':
            case 'village':
                return 'prefecture';


             case 'ward':
             if (attempts >= maxAttempts) {
                 return 'city';
                console.warn(
                    '[R14-02] FestivalStallPlacement ' +
                    'festival/venue filter initialization timed out.'
                );
                 return;
            }


             default:
             festivalVenueFilterRetryTimer =
                 return null;
                 window.setTimeout(
                    trySetup,
                    100
                );
         }
         }
        trySetup();
     }
     }


     function getParentTypeMessage(areaType) {
     startFestivalVenueFilterSetup();
        switch (areaType) {
            case 'city':
                return '市の上位地域には都道府県を指定してください。';


            case 'special_ward':
    mw.hook(
                return '特別区の上位地域には都道府県を指定してください。';
        'pf.formSetupAfter'
    ).add(
        startFestivalVenueFilterSetup
    );
});


            case 'town':
                return '町の上位地域には都道府県を指定してください。';


            case 'village':
/* =========================================
                return '村の上位地域には都道府県を指定してください。';
* FestivalStallPlacement:
* 会場連動地図ピン → 緯度・経度
* ========================================= */
$(function () {
    const venueSelect = document.querySelector(
        'select[name="FestivalStallPlacement[venue_id]"]'
    );


            case 'ward':
    const latInput = document.querySelector(
                return '行政区の上位地域には市を指定してください。';
        'input[name="FestivalStallPlacement[latitude]"]'
    );


            default:
    const lonInput = document.querySelector(
                return '地域区分と上位地域の組み合わせが正しくありません。';
        'input[name="FestivalStallPlacement[longitude]"]'
        }
     );
     }


     function validateAreaParent() {
     if (
         const areaIdInput =
         !venueSelect ||
            document.querySelector(
        !latInput ||
                'input[name="Area[area_id]"]'
        !lonInput
            );
    ) {
        return;
    }


        const areaTypeSelect =
    mw.loader.using(
            document.querySelector(
        'ext.pageforms.leaflet'
                'select[name="Area[area_type]"]'
    ).then(function () {
            );
 
        const parentIdInput =
            document.querySelector(
                'input[name="Area[parent_id]"]'
            );
 
        /*
        * Areaフォーム以外では何もしない。
        */
         if (
         if (
             !areaIdInput ||
             document.getElementById(
            !areaTypeSelect ||
                'matsuri-placement-location-map'
             !parentIdInput
             )
         ) {
         ) {
             return;
             return;
         }
         }


         const thisRequest = ++requestId;
         const api = new mw.Api();


         areaIdInput.setCustomValidity('');
         const mapDiv =
        parentIdInput.setCustomValidity('');
            document.createElement('div');


         const areaIdText =
         mapDiv.id =
             areaIdInput.value.trim();
             'matsuri-placement-location-map';


         const parentIdText =
         mapDiv.style.height = '400px';
            parentIdInput.value.trim();
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';


         const areaType =
         const help =
             areaTypeSelect.value;
             document.createElement('div');


         /*
         help.textContent =
        * Area ID は正の整数。
             '会場を選択すると会場周辺を表示します。' +
        */
             '地図をクリックして実際の屋台位置を指定してください。' +
        if (
             'ピンはドラッグして微調整できます。';
            !/^[1-9][0-9]*$/.test(
                areaIdText
             )
        ) {
             areaIdInput.setCustomValidity(
                'Area IDは1以上の整数で入力してください。'
             );


            return;
        help.style.marginBottom = '8px';
        }


         const areaId =
         const wrapper =
             Number(areaIdText);
             document.createElement('div');
 
        wrapper.appendChild(help);
        wrapper.appendChild(mapDiv);
 
        const latRow =
            latInput.closest('tr');


        /*
        * parent_id は0以上の整数。
        */
         if (
         if (
             !/^(0|[1-9][0-9]*)$/.test(
             !latRow ||
                parentIdText
             !latRow.parentNode
             )
         ) {
         ) {
            parentIdInput.setCustomValidity(
                '上位地域IDは0以上の整数で入力してください。'
            );
             return;
             return;
         }
         }


         const parentId =
         const mapRow =
             Number(parentIdText);
             document.createElement('tr');
 
        const th =
            document.createElement('th');
 
        th.textContent =
            '出店位置を地図から選択';


         const expectedParentType =
         const td =
             getExpectedParentType(
             document.createElement('td');
                areaType
            );


         /*
         td.appendChild(wrapper);
        * 想定外のarea_type。
        */
        if (
            expectedParentType === null
        ) {
            areaTypeSelect.setCustomValidity(
                '地域区分を正しく選択してください。'
            );


            return;
        mapRow.appendChild(th);
         }
         mapRow.appendChild(td);


         areaTypeSelect.setCustomValidity('');
         latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );


         /*
         /*
         * 都道府県は必ずROOT。
         * 初期状態は日本全体。
        *
        * 既存Placementに座標がある場合は
        * 後でその位置へ移動する。
         */
         */
         if (
         const map = L.map(
             areaType === 'prefecture'
             mapDiv
         ) {
         ).setView(
             if (parentId !== 0) {
            [ 36.2048, 138.2529 ],
                parentIdInput.setCustomValidity(
             5
                    '都道府県の上位地域IDは0にしてください。'
        );
                 );
 
 
        L.tileLayer(
                return;
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                 maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
             }
             }
        ).addTo(map);


             /*
        let marker = null;
            * 都道府県 parent_id=0 は正常。
        let venueRequestId = 0;
            */
 
             return;
        const venueStatus =
         }
            document.createElement(
                'div'
             );
 
        venueStatus.className =
            'matsuri-venue-location-status';
 
        venueStatus.setAttribute(
             'aria-live',
            'polite'
        );
 
         venueStatus.style.marginBottom =
            '8px';


        /*
         if (mapDiv.parentNode) {
        * 都道府県以外は必ず親を持つ。
             mapDiv.parentNode.insertBefore(
        */
                 venueStatus,
         if (parentId === 0) {
                 mapDiv
             parentIdInput.setCustomValidity(
                 getParentTypeMessage(
                    areaType
                 )
             );
             );
        }


             return;
        function setVenueStatus(message) {
             venueStatus.textContent =
                message;
         }
         }


         /*
         function resetVenueView() {
        * 自己参照。
             map.setView(
        */
                 [ 36.2048, 138.2529 ],
        if (parentId === areaId) {
                5
             parentIdInput.setCustomValidity(
                 '自分自身を上位地域に設定することはできません。'
             );
             );
            return;
         }
         }


         /*
         function dispatchInputEvents(input) {
        * API確認中は保存を止める。
            input.dispatchEvent(
        */
                new Event(
        parentIdInput.setCustomValidity(
                    'input',
            '上位地域を確認しています。'
                    { bubbles: true }
        );
                )
            );


        function isStaleRequest() {
             input.dispatchEvent(
             return (
                 new Event(
                 thisRequest !== requestId ||
                    'change',
                !document.contains(
                     { bubbles: true }
                     parentIdInput
                 )
                 )
             );
             );
         }
         }


        /*
         function updateInputs(lat, lon) {
        * 親を順番に辿って循環参照を確認。
             latInput.value =
        */
                 Number(lat).toFixed(6);
         function walkParentChain(
            nextParentId,
            visited
        ) {
             if (
                nextParentId === undefined ||
                nextParentId === null ||
                String(nextParentId).trim() === '' ||
                 Number(nextParentId) === 0
            ) {
                return Promise.resolve(true);
            }


             const numericParentId =
             lonInput.value =
                 Number(nextParentId);
                 Number(lon).toFixed(6);


             /*
             /*
             * 現在編集中のAreaへ戻れば循環。
             * 既存の必須・日本範囲チェックを
            * そのまま発火させる。
             */
             */
             if (
             dispatchInputEvents(latInput);
                numericParentId === areaId
            dispatchInputEvents(lonInput);
             ) {
        }
                 parentIdInput.setCustomValidity(
 
                     'この上位地域を設定すると地域階層が循環するため指定できません。'
        /*
                );
        * R10-5C ISSUE-06B:
        * PageForms配下のLeaflet default PNGは
        * この環境ではHTMLへredirectされるため、
        * 外部画像に依存しないdivIconを使用。
        */
        const placementMarkerIcon =
             L.divIcon({
                 className:
                     'matsuri-placement-marker-icon',


                 return Promise.resolve(false);
                 html:
            }
                    '<svg xmlns="http://www.w3.org/2000/svg" ' +
                    'width="28" height="42" viewBox="0 0 28 42" ' +
                    'aria-hidden="true" focusable="false">' +
                    '<path d="M14 1C6.8 1 1 6.8 1 14c0 10 13 27 13 27s13-17 13-27C27 6.8 21.2 1 14 1Z" ' +
                    'fill="#2a81cb" stroke="#ffffff" stroke-width="2"/>' +
                    '<circle cx="14" cy="14" r="5" fill="#ffffff"/>' +
                    '</svg>',


            /*
                 iconSize:
            * 既存データ側ですでに循環している場合。
                     [
            */
                        28,
            if (
                        42
                 visited.has(
                     ],
                     numericParentId
                )
            ) {
                parentIdInput.setCustomValidity(
                     '選択した上位地域の階層に循環があります。'
                );


                 return Promise.resolve(false);
                 iconAnchor:
             }
                    [
                        14,
                        40
                    ]
             });


             visited.add(
        function createMarker(latlng) {
                 numericParentId
             marker = L.marker(
            );
                 latlng,
                {
                    draggable:
                        true,


            return getAreaById(
                    icon:
                numericParentId
                        placementMarkerIcon
            ).then(function (rows) {
                if (isStaleRequest()) {
                    return false;
                 }
                 }
            ).addTo(map);


                 if (rows.length !== 1) {
            marker.on(
                     parentIdInput.setCustomValidity(
                'dragend',
                         '上位地域の階層情報を確認できませんでした。'
                 function () {
                     const position =
                        marker.getLatLng();
 
                    updateInputs(
                         position.lat,
                        position.lng
                     );
                     );
                    return false;
                 }
                 }
 
             );
                return walkParentChain(
                    rows[0].parent_id,
                    visited
                );
             });
         }
         }


         return getAreaById(
         function placeMarker(latlng) {
            parentId
             if (marker) {
        ).then(function (parentRows) {
                marker.setLatLng(latlng);
             if (isStaleRequest()) {
            } else {
                 return false;
                 createMarker(latlng);
             }
             }


             /*
             updateInputs(
            * 存在しないparent_id。
                latlng.lat,
            */
                 latlng.lng
            if (parentRows.length === 0) {
            );
                 parentIdInput.setCustomValidity(
        }
                    '指定した上位地域IDは存在しません。'
                );


                 return false;
        function removeMarker() {
            if (!marker) {
                 return;
             }
             }


             /*
             map.removeLayer(marker);
            * area_id重複がDB側に存在する異常状態。
            marker = null;
            */
        }
            if (parentRows.length > 1) {
 
                parentIdInput.setCustomValidity(
        function clearCoordinates() {
                    '同じArea IDの地域が複数存在するため、上位地域を特定できません。'
            latInput.value = '';
                );
            lonInput.value = '';


                return false;
            dispatchInputEvents(latInput);
             }
             dispatchInputEvents(lonInput);
        }


             const selectedParent =
        function getCurrentCoordinates() {
                 parentRows[0];
            const lat =
                Number(latInput.value);
 
             const lon =
                 Number(lonInput.value);


            /*
            * 地域区分と親地域区分の整合性。
            */
             if (
             if (
                 selectedParent.area_type !==
                 latInput.value.trim() === '' ||
                 expectedParentType
                 lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
             ) {
             ) {
                 parentIdInput.setCustomValidity(
                 return null;
                     getParentTypeMessage(
            }
                        areaType
 
                     )
            return {
                lat: lat,
                lng: lon
            };
        }
 
        function escapeCargoValue(value) {
            return String(value)
                .replace(
                     /'/g,
                     "''"
                 );
                 );
        }
        /*
        * 選択されたVenueの座標へ
        * 地図だけ移動する。
        *
        * Placementのlatitude/longitudeには
        * コピーしない。
        */
        function centerOnVenue() {
            const currentRequest =
                ++venueRequestId;


                 return false;
            const venuePage =
            }
                 venueSelect.value.trim();


             const visited =
             if (venuePage === '') {
                 new Set([
                 resetVenueView();
                    parentId
                ]);


            return walkParentChain(
                 setVenueStatus(
                selectedParent.parent_id,
                    '会場は未指定です。' +
                 visited
                    '地図上で場所を指定できます。'
            );
                 );
        }).then(function (isValid) {
            if (
                isValid === true &&
                 !isStaleRequest()
            ) {
                parentIdInput.setCustomValidity('');
            }


            return isValid;
                 return;
        }, function (error) {
            if (isStaleRequest()) {
                 return false;
             }
             }


             parentIdInput.setCustomValidity(
             setVenueStatus(
                 '上位地域を確認できませんでした。'
                 '選択した会場の位置情報を確認しています。'
             );
             );


             console.error(
             api.get({
                 'Areaの上位地域チェックに失敗しました。',
                 action: 'cargoquery',
                 error
                 format: 'json',
             );
                tables: 'Venues',
                fields:
                    'venue_id=venue_id,' +
                    '_pageName=page_name,' +
                    'latitude=latitude,' +
                    'longitude=longitude',
                where:
                    "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
             }).then(function (data) {
                /*
                * 連続して会場を変更した場合、
                * 古いレスポンスを無視する。
                */
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }


            return false;
                const result =
        });
                    data &&
    }
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];


    /*
                if (result.length === 0) {
    * Page Forms上で値が変更された場合。
                    resetVenueView();
    */
    document.addEventListener(
        'change',
        function (event) {
            const target =
                event.target;


            if (
                     setVenueStatus(
                target &&
                         '会場情報を取得できませんでした。' +
                (
                         '地図上で場所を指定できます。'
                     target.matches(
                     );
                         'input[name="Area[area_id]"]'
                    ) ||
                    target.matches(
                         'select[name="Area[area_type]"]'
                     ) ||
                    target.matches(
                        'input[name="Area[parent_id]"]'
                    )
                )
            ) {
                validateAreaParent();
            }
        },
        true
    );


    /*
                    console.warn(
    * 初期表示時の検証。
                        '会場情報を取得できませんでした。',
    */
                        venuePage
    function validateCurrentArea() {
                    );
        if (
            document.querySelector(
                'input[name="Area[area_id]"]'
            )
        ) {
            validateAreaParent();
        }
    }


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


/* ========================================
                const row =
* FestivalCalendar 開催年4桁チェック
                    result[0].title ||
* ======================================== */
                    result[0];


$( function () {
                const lat =
                    Number(row.latitude);


    const yearInput = document.querySelector(
                const lon =
        'input[name="FestivalCalendar[year]"]'
                    Number(row.longitude);
    );


    if ( !yearInput ) {
                if (
        return;
                    row.latitude === undefined ||
    }
                    row.latitude === null ||
                    String(
                        row.latitude
                    ).trim() === '' ||
                    row.longitude === undefined ||
                    row.longitude === null ||
                    String(
                        row.longitude
                    ).trim() === '' ||
                    Number.isNaN(lat) ||
                    Number.isNaN(lon)
                ) {
                    resetVenueView();


    /*
                    setVenueStatus(
    * 二重初期化防止
                        'この会場は位置情報未登録です。' +
    */
                        '地図上で場所を指定できます。'
    if (
                    );
        yearInput.dataset
            .festivalCalendarYearValidation === '1'
    ) {
        return;
    }


    yearInput.dataset
                    console.warn(
        .festivalCalendarYearValidation = '1';
                        '選択した会場には座標が登録されていません。',
                        venuePage
                    );


    yearInput.inputMode = 'numeric';
                    return;
    yearInput.maxLength = 4;
                }


    const validateYear = function () {
                map.setView(
                    [ lat, lon ],
                    18
                );


        const value =
                setVenueStatus(
            yearInput.value.trim();
                    '選択した会場の位置を表示しています。' +
                    '必要に応じて地図上で実際の位置を指定してください。'
                );


        /*
                console.log(
        * 空欄については
                    '会場位置へ地図を移動しました。',
        * Page Forms の mandatory に任せる。
                    {
        */
                        venue: venuePage,
        if (
                        latitude: lat,
            value !== '' &&
                        longitude: lon
            !/^\d{4}$/.test( value )
                    }
        ) {
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }


            yearInput.setCustomValidity(
                resetVenueView();
                '開催年は4桁の数字で入力してください(例:2027)'
            );


        } else {
                setVenueStatus(
 
                    '会場位置の取得に失敗しました。' +
            yearInput.setCustomValidity( '' );
                    '地図上で場所を指定できます。'
                );


                console.error(
                    '会場座標の取得に失敗しました。',
                    error
                );
            });
         }
         }


    };


    yearInput.addEventListener(
        /*
        'input',
        * 地図クリック
         validateYear
        */
    );
        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
         );


    yearInput.addEventListener(
        /*
         'change',
        * 手入力された場合もピンを同期。
        validateYear
        */
    );
         function syncMarkerFromInputs() {
            const coordinates =
                getCurrentCoordinates();


    /*
            if (!coordinates) {
    * 編集画面を開いた時点の値も検査
                return;
    */
            }
    validateYear();


    window
            /*
        .matsuriFestivalCalendarYearValidationVersion =
            * User-selected / manually-entered coordinates
        '20260821-v1';
            * take priority over a late Venue response.
            */
            venueRequestId += 1;


} );
            setVenueStatus(
                '指定した位置を地図に表示しています。'
            );


/* ========================================
            if (marker) {
* FestivalCalendar 開催日の前後関係チェック
                marker.setLatLng(
* ======================================== */
                    coordinates
                );
            } else {
                createMarker(
                    coordinates
                );
            }


$( function () {
            map.setView(
                [
                    coordinates.lat,
                    coordinates.lng
                ],
                18
            );
        }


    const startInput = document.querySelector(
        latInput.addEventListener(
        'input[name="FestivalCalendar[start_date]"]'
            'change',
    );
            syncMarkerFromInputs
        );


    const endInput = document.querySelector(
        lonInput.addEventListener(
        'input[name="FestivalCalendar[end_date]"]'
            'change',
    );
            syncMarkerFromInputs
        );


    if ( !startInput || !endInput ) {
        /*
        return;
        * 会場を変更した場合。
    }
        *
        * 前の会場用の屋台座標を
        * 誤って残さないようクリアする。
        */
        venueSelect.addEventListener(
            'change',
            function (event) {
                const preservePlacementCoordinates =
                    !!(
                        event &&
                        event.detail &&
                        event.detail
                            .matsuriPreservePlacementCoordinates ===
                            true
                    );


    if (
                /*
        endInput.dataset
                * Even when the new Venue is blank,
            .festivalCalendarDateValidation === '1'
                * invalidate an older Cargo response.
    ) {
                */
        return;
                venueRequestId += 1;
    }


    endInput.dataset
                if (
        .festivalCalendarDateValidation = '1';
                    preservePlacementCoordinates
                ) {
                    /*
                    * Existing Placement coordinates
                    * take priority over Venue center.
                    *
                    * If there are no Placement
                    * coordinates, Venue is still a
                    * useful map starting point.
                    */
                    if (
                        !getCurrentCoordinates()
                    ) {
                        centerOnVenue();
                    }


    const validateDates = function () {
                    return;
                }


        const startDate =
                removeMarker();
            startInput.value.trim();
                clearCoordinates();
 
                centerOnVenue();
         const endDate =
            }
            endInput.value.trim();
         );


         /*
         /*
         * 終了日は任意。
         * 編集時:
         * 両方入力されている場合だけ前後関係を確認する。
         * 既存Placement座標を優先。
         *
         *
         * type=date の値は YYYY-MM-DD なので
         * 新規時:
         * 文字列比較で日付順を判定できる。
         * Venue座標へ地図を移動。
         */
         */
         if (
        const initialCoordinates =
             startDate !== '' &&
            getCurrentCoordinates();
            endDate !== '' &&
 
             endDate < startDate
         if (initialCoordinates) {
        ) {
             createMarker(
                initialCoordinates
             );


             endInput.setCustomValidity(
             setVenueStatus(
                 '終了日は開始日以降の日付を入力してください。'
                 '登録済みの位置を地図に表示しています。'
             );
             );


            map.setView(
                [
                    initialCoordinates.lat,
                    initialCoordinates.lng
                ],
                18
            );
         } else {
         } else {
 
             centerOnVenue();
             endInput.setCustomValidity( '' );
 
         }
         }


    };
        /*
        * R10-5C ISSUE-06A:
        * 折りたたみ中はmapが0x0なので、
        * 実際に展開された後でも
        * Leafletの内部サイズを再計算する。
        */
        const mapCollapsible =
            mapDiv.closest(
                '.mw-collapsible'
            );
 
        if (
            mapCollapsible
        ) {
            $(
                mapCollapsible
            ).on(
                'afterExpand.mw-collapsible',
                function () {
                    setTimeout(
                        function () {
                            map.invalidateSize();
                        },
                        0
                    );
                }
            );
        }


    startInput.addEventListener(
        'input',
        validateDates
    );


    startInput.addEventListener(
        /*
        'change',
        * 初期状態ですでに表示されている
         validateDates
        * ケースの既存挙動も維持。
    );
        */
        setTimeout(
            function () {
                map.invalidateSize();
            },
            100
         );


    endInput.addEventListener(
        console.log(
        'input',
            '出店位置地図ピン入力を初期化しました。'
         validateDates
         );
     );
     });
});


     endInput.addEventListener(
/* =========================================
         'change',
* Venue:公式サイトURLの形式チェック
        validateDates
* ========================================= */
$(function () {
     const officialSiteInput = document.querySelector(
         'input[name="Venue[official_site]"]'
     );
     );


     validateDates();
     if (!officialSiteInput) {
        return;
    }


     window
     officialSiteInput.inputMode = 'url';
        .matsuriFestivalCalendarDateValidationVersion =
        '20260821-v1';


} );
    const validateVenueOfficialSite = function () {
        const value = officialSiteInput.value.trim();


/* ========================================
        officialSiteInput.setCustomValidity('');
* FestivalCalendar 予想来場者数チェック
* ======================================== */


$( function () {
        /*
        * 空欄は許可。
        */
        if (value === '') {
            return;
        }


    const visitorsInput = document.querySelector(
        try {
        'input[name="FestivalCalendar[expected_visitors]"]'
            const url = new URL(value);
    );


    if ( !visitorsInput ) {
            /*
        return;
            * http:// または https:// のみ許可。
    }
            */
 
            if (
    /*
                url.protocol !== 'http:' &&
    * 二重初期化防止
                url.protocol !== 'https:'
    */
            ) {
    if (
                officialSiteInput.setCustomValidity(
        visitorsInput.dataset
                    '公式サイトURLは http:// または https:// で始まるURLを入力してください。'
            .festivalCalendarVisitorsValidation === '1'
                );
    ) {
             }
        return;
        } catch (e) {
    }
             officialSiteInput.setCustomValidity(
 
                 '公式サイトURLを正しいURL形式で入力してください。'
    visitorsInput.dataset
        .festivalCalendarVisitorsValidation = '1';
 
    visitorsInput.inputMode = 'numeric';
 
    const validateVisitors = function () {
 
        const value =
            visitorsInput.value.trim();
 
        /*
        * 空欄は許可。
        * 入力する場合は0以上の整数のみ。
        */
        if (
             value !== '' &&
            !/^\d+$/.test( value )
        ) {
 
             visitorsInput.setCustomValidity(
                 '予想来場者数は0以上の整数で入力してください。'
             );
             );
        } else {
            visitorsInput.setCustomValidity( '' );
         }
         }
     };
     };


     visitorsInput.addEventListener(
     officialSiteInput.addEventListener(
         'input',
         'input',
         validateVisitors
         validateVenueOfficialSite
     );
     );


     visitorsInput.addEventListener(
     officialSiteInput.addEventListener(
         'change',
         'change',
         validateVisitors
         validateVenueOfficialSite
     );
     );


     validateVisitors();
     officialSiteInput.addEventListener(
        'invalid',
        validateVenueOfficialSite
    );


     window
    validateVenueOfficialSite();
         .matsuriFestivalCalendarVisitorsValidationVersion =
});
         '20260821-v1';
 
/*
* Festival
* 公式URLの形式チェック
*
* 空欄は許可。
* 入力された場合は http:// または https:// のURLのみ許可する。
*/
(function () {
    const fields = [
        'official_site',
        'official_x',
        'official_instagram',
        'official_facebook',
        'official_youtube'
    ];
 
    fields.forEach(function (fieldName) {
        const input = document.querySelector(
            'input[name="Festival[' + fieldName + ']"]'
        );
 
        if (!input) {
            return;
        }
 
        input.inputMode = 'url';
 
        const validateFestivalUrl = function () {
            const value = input.value.trim();
 
            input.setCustomValidity('');
 
            if (value === '') {
                return;
            }
 
            try {
                const url = new URL(value);
 
                if (
                    url.protocol !== 'http:' &&
                    url.protocol !== 'https:'
                ) {
                    input.setCustomValidity(
                        'URLは http:// または https:// で始まるURLを入力してください。'
                    );
                }
            } catch (e) {
                input.setCustomValidity(
                    '正しいURL形式で入力してください。'
                );
            }
        };
 
        input.addEventListener('input', validateFestivalUrl);
        input.addEventListener('change', validateFestivalUrl);
        input.addEventListener('invalid', validateFestivalUrl);
    });
})();
 
/* =========================================
* FestivalType:slug形式チェック
* ========================================= */
$(function () {
    const slugInput = document.querySelector(
        'input[name="FestivalType[slug]"]'
    );
 
    if (!slugInput) {
        return;
    }
 
    slugInput.spellcheck = false;
 
    const validateFestivalTypeSlug = function () {
        const value = slugInput.value.trim();
 
        slugInput.setCustomValidity('');
 
        /*
        * 空欄の必須チェックは
        * Page Forms の mandatory に任せる。
        */
        if (value === '') {
            return;
        }
 
        /*
        * 英小文字・数字を基本とし、
        * 単語の区切りに半角ハイフンのみ許可する。
        *
        * 先頭・末尾のハイフン、
        * 連続ハイフンは許可しない。
        */
        if (
            !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
        ) {
            slugInput.setCustomValidity(
                'slugは英小文字・数字・半角ハイフンで入力してください。ハイフンは単語の区切りにのみ使用できます。'
            );
        }
    };
 
    slugInput.addEventListener(
        'input',
        validateFestivalTypeSlug
    );
 
    slugInput.addEventListener(
        'change',
        validateFestivalTypeSlug
    );
 
    slugInput.addEventListener(
        'invalid',
        validateFestivalTypeSlug
    );
 
    validateFestivalTypeSlug();
});
 
/* =========================================
* FestivalType:
* 上位分類の自己参照・循環参照チェック
*
* Page Forms が select 要素を差し替えても
* 動作するようイベント委譲を使用する。
* ========================================= */
window.matsuriFestivalTypeParentValidationVersion = '20260821-v2';
(function () {
    const currentTypeId = Number(
        mw.config.get('wgArticleId')
    );
 
    let requestId = 0;
 
    function escapeCargoValue(value) {
        return String(value).replace(
            /'/g,
            "''"
        );
    }
 
        function getTypeByName(name) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();
 
            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'FestivalTypes',
                fields:
                    'type_id=type_id,' +
                    'name=name,' +
                    'parent_id=parent_id',
                where:
                    "name='" +
                    escapeCargoValue(name) +
                    "'",
                limit: '2'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];
 
            return rows.map(function (item) {
                return item.title || item;
            });
        });
    }
 
    function getTypeById(typeId) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();
 
            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'FestivalTypes',
                fields:
                    'type_id=type_id,' +
                    'name=name,' +
                    'parent_id=parent_id',
                where:
                    'type_id=' +
                    Number(typeId),
                limit: '1'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];
 
            if (rows.length === 0) {
                return null;
            }
 
            return rows[0].title || rows[0];
        });
    }
 
    function validateFestivalTypeParent(
        parentSelect
    ) {
        const thisRequest = ++requestId;
 
        parentSelect.setCustomValidity('');
 
        const parentName =
            parentSelect.value.trim();
 
        const nameInput = document.querySelector(
            'input[name="FestivalType[name]"]'
        );
 
        const currentName =
            nameInput
                ? nameInput.value.trim()
                : '';
 
        /*
        * 親なしは正常。
        */
        if (parentName === '') {
            return;
        }
 
        /*
        * 分類名が同じならAPIを待たず
        * 即座に自己参照として拒否する。
        */
        if (
            currentName !== '' &&
            parentName === currentName
        ) {
            parentSelect.setCustomValidity(
                '自分自身を上位分類に設定することはできません。'
            );
            return;
        }
 
        /*
        * 新規ページはまだtype_idを持たないため、
        * 既存階層との循環は発生しない。
        */
        if (
            !Number.isInteger(currentTypeId) ||
            currentTypeId <= 0
        ) {
            return;
        }
 
        /*
        * API確認中はフォーム送信を止める。
        */
        parentSelect.setCustomValidity(
            '上位分類を確認しています。'
        );
 
        /*
        * 非同期処理中に別の選択へ変更された、
        * またはPage Formsがselectを差し替えたか確認する。
        */
        function isStaleRequest() {
            return (
                thisRequest !== requestId ||
                !document.contains(parentSelect)
            );
        }
 
        /*
        * 選択した分類から親を順番に辿る。
        *
        * async / await は使用せず、
        * Promise の再帰処理で階層を確認する。
        */
        function walkParentChain(
            parentId,
            visited
        ) {
            if (
                parentId === undefined ||
                parentId === null ||
                String(parentId).trim() === ''
            ) {
                return Promise.resolve(true);
            }
 
            const numericParentId =
                Number(parentId);
 
            /*
            * 現在編集中の分類へ戻れば循環。
            */
            if (
                numericParentId ===
                currentTypeId
            ) {
                parentSelect.setCustomValidity(
                    'この上位分類を設定すると分類階層が循環するため選択できません。'
                );
 
                return Promise.resolve(false);
            }
 
            /*
            * 既存データ側ですでに循環している場合。
            */
            if (
                visited.has(
                    numericParentId
                )
            ) {
                parentSelect.setCustomValidity(
                    '選択した上位分類の階層に循環があります。'
                );
 
                return Promise.resolve(false);
            }
 
            visited.add(
                numericParentId
            );
 
            return getTypeById(
                numericParentId
            ).then(function (row) {
                if (isStaleRequest()) {
                    return false;
                }
 
                if (!row) {
                    parentSelect.setCustomValidity(
                        '上位分類の階層情報を確認できませんでした。'
                    );
 
                    return false;
                }
 
                return walkParentChain(
                    row.parent_id,
                    visited
                );
            });
        }
 
        return getTypeByName(
            parentName
        ).then(function (parentRows) {
            /*
            * その間に別の選択へ変更された場合は
            * 古い結果を無視する。
            */
            if (isStaleRequest()) {
                return false;
            }
 
            if (parentRows.length === 0) {
                parentSelect.setCustomValidity(
                    '選択した上位分類を確認できませんでした。'
                );
 
                return false;
            }
 
            /*
            * 同名分類が複数ある場合は
            * parent_id を一意に決められない。
            */
            if (parentRows.length > 1) {
                parentSelect.setCustomValidity(
                    '同じ名前の祭り分類が複数存在するため、上位分類を特定できません。'
                );
 
                return false;
            }
 
            const selectedParent =
                parentRows[0];
 
            const selectedTypeId =
                Number(
                    selectedParent.type_id
                );
 
            /*
            * IDでも自己参照をチェックする。
            * 分類名を編集中に変更した場合にも有効。
            */
            if (
                selectedTypeId ===
                currentTypeId
            ) {
                parentSelect.setCustomValidity(
                    '自分自身を上位分類に設定することはできません。'
                );
 
                return false;
            }
 
            const visited =
                new Set([
                    selectedTypeId
                ]);
 
            return walkParentChain(
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                /*
                * すべて正常。
                */
                parentSelect.setCustomValidity('');
            }
 
            return isValid;
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }
 
            parentSelect.setCustomValidity(
                '上位分類を確認できませんでした。'
            );
 
            console.error(
                '祭り分類の上位分類チェックに失敗しました。',
                error
            );
 
            return false;
        });
    }
 
    /*
    * Page Forms が select を差し替えても
    * document 側で変更を拾う。
    */
document.addEventListener(
    'change',
    function (event) {
        const target =
            event.target;
 
        if (
            target &&
            target.matches(
                'select[name="FestivalType[parent_id]"]'
            )
        ) {
            validateFestivalTypeParent(
                target
            );
        }
    },
    true
);
 
    /*
    * 初期表示時にも現在存在するselectを確認。
    */
    function validateCurrentParent() {
        const parentSelect =
            document.querySelector(
                'select[name="FestivalType[parent_id]"]'
            );
 
        if (parentSelect) {
            validateFestivalTypeParent(
                parentSelect
            );
        }
    }
 
    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            validateCurrentParent
        );
    } else {
        validateCurrentParent();
    }
 
    /*
    * Page Formsによる描画後にも再確認する。
    */
    if (
        typeof mw !== 'undefined' &&
        mw.hook
    ) {
        mw.hook(
            'wikipage.content'
        ).add(
            validateCurrentParent
        );
    }
})();
 
/* =========================================
* Area:
* Area ID・上位地域・地域区分の整合性チェック
*
* 既存Areaデータの正式な階層ルール:
*
* prefecture
*  parent_id = 0
*
* city / special_ward / town / village
*  parent = prefecture
*
* ward
*  parent = city
*
* 自己参照・存在しない親・循環参照も拒否する。
*
* MediaWiki 1.43対応のため
* async / await は使用しない。
* ========================================= */
 
window.matsuriAreaParentValidationVersion = '20260821-v1';
 
(function () {
    let requestId = 0;
 
    function getAreaById(areaId) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();
 
            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Areas',
                fields:
                    'area_id=area_id,' +
                    'name=name,' +
                    'area_type=area_type,' +
                    'parent_id=parent_id',
                where:
                    'area_id=' +
                    Number(areaId),
                limit: '2'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];
 
            return rows.map(function (item) {
                return item.title || item;
            });
        });
    }
 
    function getExpectedParentType(areaType) {
        switch (areaType) {
            case 'prefecture':
                return '';
 
            case 'city':
            case 'special_ward':
            case 'town':
            case 'village':
                return 'prefecture';
 
            case 'ward':
                return 'city';
 
            default:
                return null;
        }
    }
 
    function getParentTypeMessage(areaType) {
        switch (areaType) {
            case 'city':
                return '市の上位地域には都道府県を指定してください。';
 
            case 'special_ward':
                return '特別区の上位地域には都道府県を指定してください。';
 
            case 'town':
                return '町の上位地域には都道府県を指定してください。';
 
            case 'village':
                return '村の上位地域には都道府県を指定してください。';
 
            case 'ward':
                return '行政区の上位地域には市を指定してください。';
 
            default:
                return '地域区分と上位地域の組み合わせが正しくありません。';
        }
    }
 
    function validateAreaParent() {
        const areaIdInput =
            document.querySelector(
                'input[name="Area[area_id]"]'
            );
 
        const areaTypeSelect =
            document.querySelector(
                'select[name="Area[area_type]"]'
            );
 
        const parentIdInput =
            document.querySelector(
                'input[name="Area[parent_id]"]'
            );
 
        /*
        * Areaフォーム以外では何もしない。
        */
        if (
            !areaIdInput ||
            !areaTypeSelect ||
            !parentIdInput
        ) {
            return;
        }
 
        const thisRequest = ++requestId;
 
        areaIdInput.setCustomValidity('');
        parentIdInput.setCustomValidity('');
 
        const areaIdText =
            areaIdInput.value.trim();
 
        const parentIdText =
            parentIdInput.value.trim();
 
        const areaType =
            areaTypeSelect.value;
 
        /*
        * Area ID は正の整数。
        */
        if (
            !/^[1-9][0-9]*$/.test(
                areaIdText
            )
        ) {
            areaIdInput.setCustomValidity(
                'Area IDは1以上の整数で入力してください。'
            );
 
            return;
        }
 
        const areaId =
            Number(areaIdText);
 
        /*
        * parent_id は0以上の整数。
        */
        if (
            !/^(0|[1-9][0-9]*)$/.test(
                parentIdText
            )
        ) {
            parentIdInput.setCustomValidity(
                '上位地域IDは0以上の整数で入力してください。'
            );
 
            return;
        }
 
        const parentId =
            Number(parentIdText);
 
        const expectedParentType =
            getExpectedParentType(
                areaType
            );
 
        /*
        * 想定外のarea_type。
        */
        if (
            expectedParentType === null
        ) {
            areaTypeSelect.setCustomValidity(
                '地域区分を正しく選択してください。'
            );
 
            return;
        }
 
        areaTypeSelect.setCustomValidity('');
 
        /*
        * 都道府県は必ずROOT。
        */
        if (
            areaType === 'prefecture'
        ) {
            if (parentId !== 0) {
                parentIdInput.setCustomValidity(
                    '都道府県の上位地域IDは0にしてください。'
                );
 
                return;
            }
 
            /*
            * 都道府県 parent_id=0 は正常。
            */
            return;
        }
 
        /*
        * 都道府県以外は必ず親を持つ。
        */
        if (parentId === 0) {
            parentIdInput.setCustomValidity(
                getParentTypeMessage(
                    areaType
                )
            );
 
            return;
        }
 
        /*
        * 自己参照。
        */
        if (parentId === areaId) {
            parentIdInput.setCustomValidity(
                '自分自身を上位地域に設定することはできません。'
            );
 
            return;
        }
 
        /*
        * API確認中は保存を止める。
        */
        parentIdInput.setCustomValidity(
            '上位地域を確認しています。'
        );
 
        function isStaleRequest() {
            return (
                thisRequest !== requestId ||
                !document.contains(
                    parentIdInput
                )
            );
        }
 
        /*
        * 親を順番に辿って循環参照を確認。
        */
        function walkParentChain(
            nextParentId,
            visited
        ) {
            if (
                nextParentId === undefined ||
                nextParentId === null ||
                String(nextParentId).trim() === '' ||
                Number(nextParentId) === 0
            ) {
                return Promise.resolve(true);
            }
 
            const numericParentId =
                Number(nextParentId);
 
            /*
            * 現在編集中のAreaへ戻れば循環。
            */
            if (
                numericParentId === areaId
            ) {
                parentIdInput.setCustomValidity(
                    'この上位地域を設定すると地域階層が循環するため指定できません。'
                );
 
                return Promise.resolve(false);
            }
 
            /*
            * 既存データ側ですでに循環している場合。
            */
            if (
                visited.has(
                    numericParentId
                )
            ) {
                parentIdInput.setCustomValidity(
                    '選択した上位地域の階層に循環があります。'
                );
 
                return Promise.resolve(false);
            }
 
            visited.add(
                numericParentId
            );
 
            return getAreaById(
                numericParentId
            ).then(function (rows) {
                if (isStaleRequest()) {
                    return false;
                }
 
                if (rows.length !== 1) {
                    parentIdInput.setCustomValidity(
                        '上位地域の階層情報を確認できませんでした。'
                    );
 
                    return false;
                }
 
                return walkParentChain(
                    rows[0].parent_id,
                    visited
                );
            });
        }
 
        return getAreaById(
            parentId
        ).then(function (parentRows) {
            if (isStaleRequest()) {
                return false;
            }
 
            /*
            * 存在しないparent_id。
            */
            if (parentRows.length === 0) {
                parentIdInput.setCustomValidity(
                    '指定した上位地域IDは存在しません。'
                );
 
                return false;
            }
 
            /*
            * area_id重複がDB側に存在する異常状態。
            */
            if (parentRows.length > 1) {
                parentIdInput.setCustomValidity(
                    '同じArea IDの地域が複数存在するため、上位地域を特定できません。'
                );
 
                return false;
            }
 
            const selectedParent =
                parentRows[0];
 
            /*
            * 地域区分と親地域区分の整合性。
            */
            if (
                selectedParent.area_type !==
                expectedParentType
            ) {
                parentIdInput.setCustomValidity(
                    getParentTypeMessage(
                        areaType
                    )
                );
 
                return false;
            }
 
            const visited =
                new Set([
                    parentId
                ]);
 
            return walkParentChain(
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                parentIdInput.setCustomValidity('');
            }
 
            return isValid;
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }
 
            parentIdInput.setCustomValidity(
                '上位地域を確認できませんでした。'
            );
 
            console.error(
                'Areaの上位地域チェックに失敗しました。',
                error
            );
 
            return false;
        });
    }
 
    /*
    * Page Forms上で値が変更された場合。
    */
    document.addEventListener(
        'change',
        function (event) {
            const target =
                event.target;
 
            if (
                target &&
                (
                    target.matches(
                        'input[name="Area[area_id]"]'
                    ) ||
                    target.matches(
                        'select[name="Area[area_type]"]'
                    ) ||
                    target.matches(
                        'input[name="Area[parent_id]"]'
                    )
                )
            ) {
                validateAreaParent();
            }
        },
        true
    );
 
    /*
    * 初期表示時の検証。
    */
    function validateCurrentArea() {
        if (
            document.querySelector(
                'input[name="Area[area_id]"]'
            )
        ) {
            validateAreaParent();
        }
    }
 
    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            validateCurrentArea
        );
    } else {
        validateCurrentArea();
    }
}());
 
/* ========================================
* FestivalCalendar 開催年4桁チェック
* ======================================== */
 
$( function () {
 
    const yearInput = document.querySelector(
        'input[name="FestivalCalendar[year]"]'
    );
 
    if ( !yearInput ) {
        return;
    }
 
    /*
    * 二重初期化防止
    */
    if (
        yearInput.dataset
            .festivalCalendarYearValidation === '1'
    ) {
        return;
    }
 
    yearInput.dataset
        .festivalCalendarYearValidation = '1';
 
    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;
 
    const validateYear = function () {
 
        const value =
            yearInput.value.trim();
 
        /*
        * 空欄については
        * Page Forms の mandatory に任せる。
        */
        if (
            value !== '' &&
            !/^\d{4}$/.test( value )
        ) {
 
            yearInput.setCustomValidity(
                '開催年は4桁の数字で入力してください(例:2027)'
            );
 
        } else {
 
            yearInput.setCustomValidity( '' );
 
        }
 
    };
 
    yearInput.addEventListener(
        'input',
        validateYear
    );
 
    yearInput.addEventListener(
        'change',
        validateYear
    );
 
    /*
    * 編集画面を開いた時点の値も検査
    */
    validateYear();
 
     window
         .matsuriFestivalCalendarYearValidationVersion =
         '20260821-v1';


} );
} );
/* ========================================
* FestivalCalendar 開催日の前後関係チェック
* ======================================== */
$( function () {
    const startInput = document.querySelector(
        'input[name="FestivalCalendar[start_date]"]'
    );
    const endInput = document.querySelector(
        'input[name="FestivalCalendar[end_date]"]'
    );
    if ( !startInput || !endInput ) {
        return;
    }
    if (
        endInput.dataset
            .festivalCalendarDateValidation === '1'
    ) {
        return;
    }
    endInput.dataset
        .festivalCalendarDateValidation = '1';
    const validateDates = function () {
        const startDate =
            startInput.value.trim();
        const endDate =
            endInput.value.trim();
        /*
        * 終了日は任意。
        * 両方入力されている場合だけ前後関係を確認する。
        *
        * type=date の値は YYYY-MM-DD なので
        * 文字列比較で日付順を判定できる。
        */
        if (
            startDate !== '' &&
            endDate !== '' &&
            endDate < startDate
        ) {
            endInput.setCustomValidity(
                '終了日は開始日以降の日付を入力してください。'
            );
        } else {
            endInput.setCustomValidity( '' );
        }
    };
    startInput.addEventListener(
        'input',
        validateDates
    );
    startInput.addEventListener(
        'change',
        validateDates
    );
    endInput.addEventListener(
        'input',
        validateDates
    );
    endInput.addEventListener(
        'change',
        validateDates
    );
    validateDates();
    window
        .matsuriFestivalCalendarDateValidationVersion =
        '20260821-v1';
} );
/* ========================================
* FestivalCalendar 予想来場者数チェック
* ======================================== */
$( function () {
    const visitorsInput = document.querySelector(
        'input[name="FestivalCalendar[expected_visitors]"]'
    );
    if ( !visitorsInput ) {
        return;
    }
    /*
    * 二重初期化防止
    */
    if (
        visitorsInput.dataset
            .festivalCalendarVisitorsValidation === '1'
    ) {
        return;
    }
    visitorsInput.dataset
        .festivalCalendarVisitorsValidation = '1';
    visitorsInput.inputMode = 'numeric';
    const validateVisitors = function () {
        const value =
            visitorsInput.value.trim();
        /*
        * 空欄は許可。
        * 入力する場合は0以上の整数のみ。
        */
        if (
            value !== '' &&
            !/^\d+$/.test( value )
        ) {
            visitorsInput.setCustomValidity(
                '予想来場者数は0以上の整数で入力してください。'
            );
        } else {
            visitorsInput.setCustomValidity( '' );
        }
    };
    visitorsInput.addEventListener(
        'input',
        validateVisitors
    );
    visitorsInput.addEventListener(
        'change',
        validateVisitors
    );
    validateVisitors();
    window
        .matsuriFestivalCalendarVisitorsValidationVersion =
        '20260821-v1';
} );
/*
* R9-3 MatsuriWiki privacy-safe photo uploader candidate.
*
* Candidate mode:
*  REAL_UPLOAD_ENABLED = false
*
* No production upload can occur while this flag is false.
*/
(function () {
    'use strict';
    const REAL_UPLOAD_ENABLED = true;
    const UI_ID =
        'r9-photo-privacy-uploader';
    const MAX_OUTPUT_BYTES =
        1536 * 1024;
    const MAX_EDGE =
        1600;
    const MIN_EDGE =
        720;
    const MAIN_SELECTOR =
        '[name="FestivalStallPlacement[main_image]"]';
    const POSITION_SELECTOR =
        '[name="FestivalStallPlacement[position_status]"]';
    const LAT_SELECTOR =
        '[name="FestivalStallPlacement[latitude]"]';
    const LON_SELECTOR =
        '[name="FestivalStallPlacement[longitude]"]';
    function text(codePoints) {
        return codePoints
            .map(function (n) {
                return String.fromCodePoint(n);
            })
            .join('');
    }
    const LABELS = {
        title:
            '\u5199\u771f\u3092\u5b89\u5168\u306b\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9',
        help:
            '\u5199\u771f\u306f\u30d6\u30e9\u30a6\u30b6\u5185\u3067\u753b\u50cf\u5316\u3057\u76f4\u3057\u3001GPS\u306a\u3069\u306eEXIF\u30e1\u30bf\u30c7\u30fc\u30bf\u3092\u9664\u53bb\u3057\u3066\u304b\u3089JPEG\u3068\u3057\u3066\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002',
        select:
            '\u5199\u771f\u3092\u9078\u629e',
        publicName:
            '\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d',
        gpsFound:
            'GPS\u4f4d\u7f6e\u5019\u88dc\u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f\u3002\u81ea\u52d5\u3067\u5ea7\u6a19\u306f\u5909\u66f4\u3057\u307e\u305b\u3093\u3002',
        gpsNotFound:
            'GPS\u4f4d\u7f6e\u5019\u88dc\u306f\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002',
        adoptGps:
            '\u3053\u306e\u4f4d\u7f6e\u5019\u88dc\u3092\u4f7f\u7528',
        upload:
            '\u5b89\u5168\u306b\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9',
        reset:
            '\u9078\u629e\u3057\u305f\u5199\u771f\u3092\u30ea\u30bb\u30c3\u30c8',
        processing:
            '\u30d6\u30e9\u30a6\u30b6\u5185\u3067\u5b89\u5168\u51e6\u7406\u4e2d...',
        ready:
            '\u30b5\u30cb\u30bf\u30a4\u30ba\u6e08\u307fJPEG\u306e\u6e96\u5099\u304c\u3067\u304d\u307e\u3057\u305f\u3002',
        dryRun:
            'R9-3 candidate\u306f\u30c9\u30e9\u30a4\u30e9\u30f3\u4e2d\u306e\u305f\u3081\u3001\u5b9f\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306f\u7121\u52b9\u3067\u3059\u3002',
        uploadSuccess:
            '\u5b89\u5168\u306aJPEG\u306e\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u3002',
        uploadFailure:
            '\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002',
        existingFile:
            '\u540c\u3058\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d\u304c\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059\u3002\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u5909\u66f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002',
        badType:
            'JPG\u3001PNG\u3001WebP\u306e\u3044\u305a\u308c\u304b\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002',
        badName:
            '\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002',
        gpsApplied:
            '\u4f4d\u7f6e\u5019\u88dc\u3092\u7def\u5ea6\u30fb\u7d4c\u5ea6\u306b\u53cd\u6620\u3057\u307e\u3057\u305f\u3002\u4f4d\u7f6e\u78ba\u8a8d\u72b6\u614b\u306f\u5909\u66f4\u3057\u3066\u3044\u307e\u305b\u3093\u3002',
        replaceCoords:
            '\u3059\u3067\u306b\u5165\u529b\u3055\u308c\u3066\u3044\u308b\u7def\u5ea6\u30fb\u7d4c\u5ea6\u3092\u3001\u5199\u771f\u306e\u4f4d\u7f6e\u5019\u88dc\u3067\u7f6e\u304d\u63db\u3048\u307e\u3059\u304b\uff1f'
    };
    function dispatchInputChange(element) {
        element.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );
        element.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }
    function ascii(view, offset, count) {
        let out = '';
        if (
            offset < 0 ||
            count < 0 ||
            offset + count > view.byteLength
        ) {
            return '';
        }
        for (
            let i = 0;
            i < count;
            i++
        ) {
            const n =
                view.getUint8(
                    offset + i
                );
            if (n === 0) {
                break;
            }
            out +=
                String.fromCharCode(n);
        }
        return out;
    }
    function detectImageType(buffer) {
        const view =
            new DataView(buffer);
        if (
            view.byteLength >= 3 &&
            view.getUint8(0) === 0xff &&
            view.getUint8(1) === 0xd8 &&
            view.getUint8(2) === 0xff
        ) {
            return 'image/jpeg';
        }
        if (
            view.byteLength >= 8 &&
            view.getUint32(0, false) ===
                0x89504e47 &&
            view.getUint32(4, false) ===
                0x0d0a1a0a
        ) {
            return 'image/png';
        }
        if (
            view.byteLength >= 12 &&
            ascii(
                view,
                0,
                4
            ) === 'RIFF' &&
            ascii(
                view,
                8,
                4
            ) === 'WEBP'
        ) {
            return 'image/webp';
        }
        return '';
    }
    function jpegExif(buffer) {
        const view =
            new DataView(buffer);
        if (
            view.byteLength < 4 ||
            view.getUint16(
                0,
                false
            ) !== 0xffd8
        ) {
            return null;
        }
        let p = 2;
        while (
            p + 4 <=
            view.byteLength
        ) {
            if (
                view.getUint8(p) !==
                0xff
            ) {
                p++;
                continue;
            }
            const marker =
                view.getUint8(
                    p + 1
                );
            if (
                marker === 0xda ||
                marker === 0xd9
            ) {
                return null;
            }
            if (
                marker >= 0xd0 &&
                marker <= 0xd7
            ) {
                p += 2;
                continue;
            }
            if (
                p + 4 >
                view.byteLength
            ) {
                return null;
            }
            const len =
                view.getUint16(
                    p + 2,
                    false
                );
            if (
                len < 2 ||
                p + 2 + len >
                    view.byteLength
            ) {
                return null;
            }
            if (
                marker === 0xe1 &&
                ascii(
                    view,
                    p + 4,
                    6
                ) === 'Exif'
            ) {
                return {
                    view:
                        view,
                    tiff:
                        p + 10
                };
            }
            p +=
                2 + len;
        }
        return null;
    }
    function pngExif(buffer) {
        const view =
            new DataView(buffer);
        if (
            detectImageType(
                buffer
            ) !== 'image/png'
        ) {
            return null;
        }
        let p = 8;
        while (
            p + 12 <=
            view.byteLength
        ) {
            const size =
                view.getUint32(
                    p,
                    false
                );
            const type =
                ascii(
                    view,
                    p + 4,
                    4
                );
            const data =
                p + 8;
            const end =
                data + size;
            if (
                end + 4 >
                view.byteLength
            ) {
                return null;
            }
            if (
                type === 'eXIf'
            ) {
                let tiff =
                    data;
                if (
                    size >= 6 &&
                    ascii(
                        view,
                        data,
                        6
                    ) === 'Exif'
                ) {
                    tiff += 6;
                }
                return {
                    view:
                        view,
                    tiff:
                        tiff
                };
            }
            p =
                end + 4;
        }
        return null;
    }
    function webpExif(buffer) {
        const view =
            new DataView(buffer);
        if (
            detectImageType(
                buffer
            ) !== 'image/webp'
        ) {
            return null;
        }
        let p = 12;
        while (
            p + 8 <=
            view.byteLength
        ) {
            const type =
                ascii(
                    view,
                    p,
                    4
                );
            const size =
                view.getUint32(
                    p + 4,
                    true
                );
            const data =
                p + 8;
            const end =
                data + size;
            if (
                end >
                view.byteLength
            ) {
                return null;
            }
            if (
                type === 'EXIF'
            ) {
                let tiff =
                    data;
                if (
                    size >= 6 &&
                    ascii(
                        view,
                        data,
                        6
                    ) === 'Exif'
                ) {
                    tiff += 6;
                }
                return {
                    view:
                        view,
                    tiff:
                        tiff
                };
            }
            p =
                end +
                (
                    size % 2
                );
        }
        return null;
    }
    function getExifContainer(
        buffer,
        imageType
    ) {
        if (
            imageType ===
            'image/jpeg'
        ) {
            return jpegExif(
                buffer
            );
        }
        if (
            imageType ===
            'image/png'
        ) {
            return pngExif(
                buffer
            );
        }
        if (
            imageType ===
            'image/webp'
        ) {
            return webpExif(
                buffer
            );
        }
        return null;
    }
    function parseGps(
        buffer,
        imageType
    ) {
        const found =
            getExifContainer(
                buffer,
                imageType
            );
        if (!found) {
            return null;
        }
        try {
            const view =
                found.view;
            const tiff =
                found.tiff;
            if (
                tiff < 0 ||
                tiff + 8 >
                    view.byteLength
            ) {
                return null;
            }
            const byteOrder =
                view.getUint16(
                    tiff,
                    false
                );
            const littleEndian =
                byteOrder === 0x4949
                    ? true
                    : byteOrder === 0x4d4d
                        ? false
                        : null;
            if (
                littleEndian ===
                null
            ) {
                return null;
            }
            const u16 =
                function (offset) {
                    if (
                        offset < 0 ||
                        offset + 2 >
                            view.byteLength
                    ) {
                        throw new Error(
                            'EXIF_BOUNDS'
                        );
                    }
                    return view.getUint16(
                        offset,
                        littleEndian
                    );
                };
            const u32 =
                function (offset) {
                    if (
                        offset < 0 ||
                        offset + 4 >
                            view.byteLength
                    ) {
                        throw new Error(
                            'EXIF_BOUNDS'
                        );
                    }
                    return view.getUint32(
                        offset,
                        littleEndian
                    );
                };
            if (
                u16(
                    tiff + 2
                ) !== 42
            ) {
                return null;
            }
            const ifd0 =
                tiff +
                u32(
                    tiff + 4
                );
            const ifd0Count =
                u16(ifd0);
            let gpsOffset =
                null;
            for (
                let i = 0;
                i < ifd0Count;
                i++
            ) {
                const entry =
                    ifd0 +
                    2 +
                    i * 12;
                if (
                    entry + 12 >
                    view.byteLength
                ) {
                    return null;
                }
                if (
                    u16(entry) ===
                    0x8825
                ) {
                    gpsOffset =
                        u32(
                            entry + 8
                        );
                    break;
                }
            }
            if (
                gpsOffset ===
                null
            ) {
                return null;
            }
            const gpsIfd =
                tiff +
                gpsOffset;
            const gpsCount =
                u16(gpsIfd);
            let latRef = '';
            let lonRef = '';
            let latParts = null;
            let lonParts = null;
            const rationalTriplet =
                function (offset) {
                    const values = [];
                    for (
                        let i = 0;
                        i < 3;
                        i++
                    ) {
                        const numerator =
                            u32(
                                offset +
                                i * 8
                            );
                        const denominator =
                            u32(
                                offset +
                                i * 8 +
                                4
                            );
                        if (
                            denominator ===
                            0
                        ) {
                            throw new Error(
                                'EXIF_ZERO_DENOMINATOR'
                            );
                        }
                        values.push(
                            numerator /
                            denominator
                        );
                    }
                    return values;
                };
            for (
                let i = 0;
                i < gpsCount;
                i++
            ) {
                const entry =
                    gpsIfd +
                    2 +
                    i * 12;
                if (
                    entry + 12 >
                    view.byteLength
                ) {
                    return null;
                }
                const tag =
                    u16(entry);
                const type =
                    u16(
                        entry + 2
                    );
                const count =
                    u32(
                        entry + 4
                    );
                const valueField =
                    entry + 8;
                if (
                    (
                        tag === 1 ||
                        tag === 3
                    ) &&
                    type === 2
                ) {
                    const valueOffset =
                        count <= 4
                            ? valueField
                            : tiff +
                                u32(
                                    valueField
                                );
                    const ref =
                        ascii(
                            view,
                            valueOffset,
                            count
                        )
                            .trim()
                            .toUpperCase();
                    if (
                        tag === 1
                    ) {
                        latRef =
                            ref;
                    } else {
                        lonRef =
                            ref;
                    }
                }
                if (
                    (
                        tag === 2 ||
                        tag === 4
                    ) &&
                    type === 5 &&
                    count >= 3
                ) {
                    const dataOffset =
                        tiff +
                        u32(
                            valueField
                        );
                    const parts =
                        rationalTriplet(
                            dataOffset
                        );
                    if (
                        tag === 2
                    ) {
                        latParts =
                            parts;
                    } else {
                        lonParts =
                            parts;
                    }
                }
            }
            if (
                !latRef ||
                !lonRef ||
                !latParts ||
                !lonParts
            ) {
                return null;
            }
            const decimal =
                function (parts) {
                    return (
                        parts[0] +
                        parts[1] / 60 +
                        parts[2] / 3600
                    );
                };
            let latitude =
                decimal(
                    latParts
                );
            let longitude =
                decimal(
                    lonParts
                );
            if (
                latRef ===
                'S'
            ) {
                latitude *= -1;
            }
            if (
                lonRef ===
                'W'
            ) {
                longitude *= -1;
            }
            if (
                !Number.isFinite(
                    latitude
                ) ||
                !Number.isFinite(
                    longitude
                ) ||
                latitude < -90 ||
                latitude > 90 ||
                longitude < -180 ||
                longitude > 180
            ) {
                return null;
            }
            return {
                latitude:
                    latitude,
                longitude:
                    longitude
            };
        } catch (_) {
            return null;
        }
    }
    function canvasToJpeg(
        canvas,
        quality
    ) {
        return new Promise(
            function (
                resolve,
                reject
            ) {
                canvas.toBlob(
                    function (blob) {
                        if (!blob) {
                            reject(
                                new Error(
                                    'CANVAS_TO_BLOB_FAILED'
                                )
                            );
                            return;
                        }
                        resolve(blob);
                    },
                    'image/jpeg',
                    quality
                );
            }
        );
    }
    function runAsyncGenerator(
        generator
    ) {
        return new Promise(
            function (
                resolve,
                reject
            ) {
                function step(
                    method,
                    value
                ) {
                    let result;
                    try {
                        result =
                            generator[
                                method
                            ](
                                value
                            );
                    } catch (error) {
                        reject(
                            error
                        );
                        return;
                    }
                    if (
                        result.done
                    ) {
                        resolve(
                            result.value
                        );
                        return;
                    }
                    Promise.resolve(
                        result.value
                    ).then(
                        function (
                            nextValue
                        ) {
                            step(
                                'next',
                                nextValue
                            );
                        },
                        function (
                            error
                        ) {
                            step(
                                'throw',
                                error
                            );
                        }
                    );
                }
                step(
                    'next'
                );
            }
        );
    }
    function sanitizeImage(
        file
    ) {
        return runAsyncGenerator(
            (function* () {
        let bitmap = null;
        try {
            try {
                bitmap =
                    yield createImageBitmap(
                        file,
                        {
                            imageOrientation:
                                'from-image'
                        }
                    );
            } catch (_) {
                bitmap =
                    yield createImageBitmap(
                        file
                    );
            }
            let width =
                bitmap.width;
            let height =
                bitmap.height;
            const initialScale =
                Math.min(
                    1,
                    MAX_EDGE /
                        Math.max(
                            width,
                            height
                        )
                );
            width =
                Math.max(
                    1,
                    Math.round(
                        width *
                        initialScale
                    )
                );
            height =
                Math.max(
                    1,
                    Math.round(
                        height *
                        initialScale
                    )
                );
            const qualities = [
                0.90,
                0.82,
                0.74,
                0.66,
                0.58
            ];
            for (
                let resizePass = 0;
                resizePass < 6;
                resizePass++
            ) {
                const canvas =
                    document.createElement(
                        'canvas'
                    );
                canvas.width =
                    width;
                canvas.height =
                    height;
                const context =
                    canvas.getContext(
                        '2d',
                        {
                            alpha: false
                        }
                    );
                if (!context) {
                    throw new Error(
                        'CANVAS_CONTEXT_FAILED'
                    );
                }
                context.fillStyle =
                    '#fff';
                context.fillRect(
                    0,
                    0,
                    width,
                    height
                );
                context.drawImage(
                    bitmap,
                    0,
                    0,
                    width,
                    height
                );
                for (
                    const quality
                    of qualities
                ) {
                    const blob =
                        yield canvasToJpeg(
                            canvas,
                            quality
                        );
                    if (
                        blob.size <=
                        MAX_OUTPUT_BYTES
                    ) {
                        const safeBuffer =
                            yield blob
                                .arrayBuffer();
                        if (
                            jpegExif(
                                safeBuffer
                            )
                        ) {
                            throw new Error(
                                'EXIF_REMAINED_AFTER_SANITIZE'
                            );
                        }
                        return {
                            blob:
                                blob,
                            width:
                                width,
                            height:
                                height,
                            quality:
                                quality
                        };
                    }
                }
                const nextWidth =
                    Math.round(
                        width * 0.85
                    );
                const nextHeight =
                    Math.round(
                        height * 0.85
                    );
                if (
                    Math.max(
                        nextWidth,
                        nextHeight
                    ) <
                    MIN_EDGE
                ) {
                    break;
                }
                width =
                    Math.max(
                        1,
                        nextWidth
                    );
                height =
                    Math.max(
                        1,
                        nextHeight
                    );
            }
            throw new Error(
                'SAFE_JPEG_SIZE_LIMIT_FAILED'
            );
        } finally {
            if (
                bitmap &&
                typeof bitmap.close ===
                    'function'
            ) {
                bitmap.close();
            }
        }
            }())
        );
    }
    function defaultFilename(
        originalName
    ) {
        let base =
            String(
                originalName ||
                ''
            )
                .replace(
                    /\.[^.]*$/,
                    ''
                )
                .replace(
                    /[\\/:*?"<>|#\[\]{}]+/g,
                    '-'
                )
                .replace(
                    /\s+/g,
                    ' '
                )
                .trim();
        if (!base) {
            base =
                'festival-photo';
        }
        return (
            base +
            '.jpg'
        );
    }
    function normalizedFilename(
        value
    ) {
        let name =
            String(
                value ||
                ''
            )
                .trim()
                .replace(
                    /^File:/i,
                    ''
                )
                .replace(
                    /^\u30d5\u30a1\u30a4\u30eb:/,
                    ''
                );
        if (!name) {
            return '';
        }
        name =
            name.replace(
                /[\\/:*?"<>|#\[\]{}]+/g,
                '-'
            );
        name =
            name.replace(
                /\.[^.]*$/,
                ''
            );
        name =
            name.trim();
        if (!name) {
            return '';
        }
        return (
            name +
            '.jpg'
        );
    }
    function makeElement(
        tag,
        properties
    ) {
        const element =
            document.createElement(
                tag
            );
        Object.keys(
            properties || {}
        ).forEach(
            function (key) {
                if (
                    key ===
                    'style'
                ) {
                    element.style.cssText =
                        properties[key];
                    return;
                }
                if (
                    key ===
                    'textContent'
                ) {
                    element.textContent =
                        properties[key];
                    return;
                }
                element[key] =
                    properties[key];
            }
        );
        return element;
    }
    function initUploader() {
        const mainImage =
            document.querySelector(
                MAIN_SELECTOR
            );
        if (!mainImage) {
            return;
        }
        if (
            document.getElementById(
                UI_ID
            )
        ) {
            return;
        }
        const positionStatus =
            document.querySelector(
                POSITION_SELECTOR
            );
        const latitudeInput =
            document.querySelector(
                LAT_SELECTOR
            );
        const longitudeInput =
            document.querySelector(
                LON_SELECTOR
            );
        if (
            !positionStatus ||
            !latitudeInput ||
            !longitudeInput
        ) {
            return;
        }
        const uploadLink =
            document.querySelector(
                '.ext-pageforms-uploadable' +
                '[data-input-id="' +
                CSS.escape(
                    mainImage.id
                ) +
                '"]'
            );
        if (!uploadLink) {
            return;
        }
        /*
        * Privacy fail-closed:
        * once this workflow is detected,
        * hide the standard Page Forms
        * upload route for this field.
        */
        uploadLink.hidden =
            true;
        uploadLink.setAttribute(
            'aria-hidden',
            'true'
        );
        const originalState = {
            mainImage:
                mainImage.value,
            positionStatus:
                positionStatus.value,
            latitude:
                latitudeInput.value,
            longitude:
                longitudeInput.value
        };
        const state = {
            sourceType:
                '',
            gps:
                null,
            gpsFound:
                false,
            gpsAdopted:
                false,
            safeBlob:
                null,
            safeWidth:
                0,
            safeHeight:
                0,
            safeQuality:
                0,
            safeExifPresent:
                null,
            objectUrl:
                '',
            uploadAttempted:
                false,
            uploadPerformed:
                false,
            uploadErrorCode:
                '',
            uploadedFilename:
                ''
        };
        const box =
            makeElement(
                'div',
                {
                    id:
                        UI_ID,
                    style:
                        'margin-top:.75rem;' +
                        'padding:.85rem;' +
                        'border:1px solid #a2a9b1;' +
                        'border-radius:6px;' +
                        'background:#fff;'
                }
            );
        const heading =
            makeElement(
                'strong',
                {
                    textContent:
                        LABELS.title
                }
            );
        const help =
            makeElement(
                'div',
                {
                    textContent:
                        LABELS.help,
                    style:
                        'margin:.4rem 0 .75rem;'
                }
            );
        const fileLabel =
            makeElement(
                'label',
                {
                    textContent:
                        LABELS.select,
                    style:
                        'display:block;' +
                        'font-weight:600;' +
                        'margin-bottom:.25rem;'
                }
            );
        const fileInput =
            makeElement(
                'input',
                {
                    type:
                        'file',
                    accept:
                        'image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp'
                }
            );
        const nameLabel =
            makeElement(
                'label',
                {
                    textContent:
                        LABELS.publicName,
                    style:
                        'display:block;' +
                        'font-weight:600;' +
                        'margin-top:.75rem;' +
                        'margin-bottom:.25rem;'
                }
            );
        const filenameInput =
            makeElement(
                'input',
                {
                    type:
                        'text',
                    style:
                        'box-sizing:border-box;' +
                        'width:100%;' +
                        'max-width:32rem;'
                }
            );
        const status =
            makeElement(
                'div',
                {
                    style:
                        'margin-top:.65rem;'
                }
            );
        const info =
            makeElement(
                'div',
                {
                    style:
                        'margin-top:.35rem;' +
                        'font-size:.95em;'
                }
            );
        const preview =
            makeElement(
                'img',
                {
                    alt:
                        'privacy-safe local preview',
                    hidden:
                        true,
                    style:
                        'display:none;' +
                        'max-width:260px;' +
                        'max-height:360px;' +
                        'object-fit:contain;' +
                        'margin-top:.65rem;'
                }
            );
        const actions =
            makeElement(
                'div',
                {
                    style:
                        'display:flex;' +
                        'flex-wrap:wrap;' +
                        'gap:.5rem;' +
                        'margin-top:.75rem;'
                }
            );
        const adoptButton =
            makeElement(
                'button',
                {
                    type:
                        'button',
                    textContent:
                        LABELS.adoptGps,
                    hidden:
                        true
                }
            );
        const uploadButton =
            makeElement(
                'button',
                {
                    type:
                        'button',
                    textContent:
                        LABELS.upload,
                    disabled:
                        true
                }
            );
        const resetButton =
            makeElement(
                'button',
                {
                    type:
                        'button',
                    textContent:
                        LABELS.reset,
                    disabled:
                        true
                }
            );
        fileLabel.appendChild(
            fileInput
        );
        actions.append(
            adoptButton,
            uploadButton,
            resetButton
        );
        box.append(
            heading,
            help,
            fileLabel,
            nameLabel,
            filenameInput,
            status,
            info,
            preview,
            actions
        );
        const previewWrapper =
            document.getElementById(
                mainImage.id +
                '_imagepreview'
            );
        (
            previewWrapper ||
            uploadLink
        ).insertAdjacentElement(
            'afterend',
            box
        );
        function revokePreview() {
            if (
                state.objectUrl
            ) {
                URL.revokeObjectURL(
                    state.objectUrl
                );
                state.objectUrl =
                    '';
            }
        }
        function resetSelection() {
            revokePreview();
            fileInput.value =
                '';
            filenameInput.value =
                '';
            preview.removeAttribute(
                'src'
            );
            preview.hidden =
                true;
            preview.style.display =
                'none';
            adoptButton.hidden =
                true;
            uploadButton.disabled =
                true;
            resetButton.disabled =
                true;
            state.sourceType =
                '';
            state.gps =
                null;
            state.gpsFound =
                false;
            state.gpsAdopted =
                false;
            state.safeBlob =
                null;
            state.safeWidth =
                0;
            state.safeHeight =
                0;
            state.safeQuality =
                0;
            state.safeExifPresent =
                null;
            state.uploadAttempted =
                false;
            state.uploadPerformed =
                false;
            state.uploadErrorCode =
                '';
            state.uploadedFilename =
                '';
            status.textContent =
                '';
            info.textContent =
                '';
        }
        function safeStateReport() {
            return {
                realUploadEnabled:
                    REAL_UPLOAD_ENABLED,
                sourceType:
                    state.sourceType,
                gpsFound:
                    state.gpsFound,
                gpsAdopted:
                    state.gpsAdopted,
                gpsValuesPrinted:
                    false,
                sanitizedReady:
                    !!state.safeBlob,
                sanitizedType:
                    state.safeBlob
                        ? state.safeBlob.type
                        : '',
                sanitizedSize:
                    state.safeBlob
                        ? state.safeBlob.size
                        : 0,
                sanitizedWidth:
                    state.safeWidth,
                sanitizedHeight:
                    state.safeHeight,
                sanitizedExifApp1Found:
                    state.safeExifPresent,
                outputWithin1536KiB:
                    !!state.safeBlob &&
                    state.safeBlob.size <=
                        MAX_OUTPUT_BYTES,
                publicFilename:
                    normalizedFilename(
                        filenameInput.value
                    ),
                mainImageChanged:
                    mainImage.value !==
                    originalState.mainImage,
                positionStatusChanged:
                    positionStatus.value !==
                    originalState.positionStatus,
                latitudeHasValue:
                    !!latitudeInput.value
                        .trim(),
                longitudeHasValue:
                    !!longitudeInput.value
                        .trim(),
                uploadAttempted:
                    state.uploadAttempted,
                uploadPerformed:
                    state.uploadPerformed,
                uploadErrorCode:
                    state.uploadErrorCode,
                uploadedFilename:
                    state.uploadedFilename,
                standardUploadHidden:
                    uploadLink.hidden ===
                    true
            };
        }
        window
            .__r9PhotoPrivacyUploaderState =
            safeStateReport;
        window
            .__r9PhotoPrivacyUploaderCleanup =
            function () {
                revokePreview();
                mainImage.value =
                    originalState.mainImage;
                positionStatus.value =
                    originalState.positionStatus;
                latitudeInput.value =
                    originalState.latitude;
                longitudeInput.value =
                    originalState.longitude;
                box.remove();
                uploadLink.hidden =
                    false;
                uploadLink.removeAttribute(
                    'aria-hidden'
                );
                delete window
                    .__r9PhotoPrivacyUploaderState;
                delete window
                    .__r9PhotoPrivacyUploaderCleanup;
            };
        fileInput.addEventListener(
            'change',
            function () {
                return runAsyncGenerator(
                    (function* () {
                const file =
                    fileInput.files &&
                    fileInput.files[0];
                if (!file) {
                    return;
                }
                revokePreview();
                state.gps =
                    null;
                state.gpsFound =
                    false;
                state.gpsAdopted =
                    false;
                state.safeBlob =
                    null;
                state.safeExifPresent =
                    null;
                state.uploadAttempted =
                    false;
                state.uploadPerformed =
                    false;
                state.uploadErrorCode =
                    '';
                state.uploadedFilename =
                    '';
                uploadButton.disabled =
                    true;
                resetButton.disabled =
                    false;
                adoptButton.hidden =
                    true;
                preview.hidden =
                    true;
                preview.style.display =
                    'none';
                status.textContent =
                    LABELS.processing;
                info.textContent =
                    '';
                try {
                    const originalBuffer =
                        yield file
                            .arrayBuffer();
                    const imageType =
                        detectImageType(
                            originalBuffer
                        );
                    if (
                        ![
                            'image/jpeg',
                            'image/png',
                            'image/webp'
                        ].includes(
                            imageType
                        )
                    ) {
                        throw new Error(
                            'UNSUPPORTED_IMAGE_TYPE'
                        );
                    }
                    state.sourceType =
                        imageType;
                    state.gps =
                        parseGps(
                            originalBuffer,
                            imageType
                        );
                    state.gpsFound =
                        !!state.gps;
                    const safe =
                        yield sanitizeImage(
                            file
                        );
                    state.safeBlob =
                        safe.blob;
                    state.safeWidth =
                        safe.width;
                    state.safeHeight =
                        safe.height;
                    state.safeQuality =
                        safe.quality;
                    const safeBuffer =
                        yield safe.blob
                            .arrayBuffer();
                    state.safeExifPresent =
                        !!jpegExif(
                            safeBuffer
                        );
                    if (
                        state.safeExifPresent
                    ) {
                        throw new Error(
                            'SANITIZED_JPEG_HAS_EXIF'
                        );
                    }
                    filenameInput.value =
                        defaultFilename(
                            file.name
                        );
                    state.objectUrl =
                        URL.createObjectURL(
                            safe.blob
                        );
                    preview.src =
                        state.objectUrl;
                    preview.hidden =
                        false;
                    preview.style.display =
                        'block';
                    adoptButton.hidden =
                        !state.gpsFound;
                    uploadButton.disabled =
                        false;
                    status.textContent =
                        state.gpsFound
                            ? LABELS.gpsFound
                            : LABELS.gpsNotFound;
                    info.textContent =
                        LABELS.ready +
                        ' ' +
                        safe.width +
                        ' x ' +
                        safe.height +
                        ' / ' +
                        Math.ceil(
                            safe.blob.size /
                            1024
                        ) +
                        ' KiB';
                } catch (error) {
                    state.safeBlob =
                        null;
                    uploadButton.disabled =
                        true;
                    adoptButton.hidden =
                        true;
                    preview.hidden =
                        true;
                    preview.style.display =
                        'none';
                    if (
                        error &&
                        error.message ===
                            'UNSUPPORTED_IMAGE_TYPE'
                    ) {
                        status.textContent =
                            LABELS.badType;
                    } else {
                        status.textContent =
                            'R9 local processing error: ' +
                            (
                                error &&
                                error.message
                                    ? error.message
                                    : 'UNKNOWN'
                            );
                    }
                }
                    }())
                );
            }
        );
        adoptButton.addEventListener(
            'click',
            function () {
                if (!state.gps) {
                    return;
                }
                const existingCoordinates =
                    !!latitudeInput.value
                        .trim() ||
                    !!longitudeInput.value
                        .trim();
                if (
                    existingCoordinates &&
                    !window.confirm(
                        LABELS.replaceCoords
                    )
                ) {
                    return;
                }
                latitudeInput.value =
                    state.gps
                        .latitude
                        .toFixed(8);
                longitudeInput.value =
                    state.gps
                        .longitude
                        .toFixed(8);
                dispatchInputChange(
                    latitudeInput
                );
                dispatchInputChange(
                    longitudeInput
                );
                state.gpsAdopted =
                    true;
                status.textContent =
                    LABELS.gpsApplied;
            }
        );
        resetButton.addEventListener(
            'click',
            function () {
                resetSelection();
            }
        );
        uploadButton.addEventListener(
            'click',
            function () {
                return runAsyncGenerator(
                    (function* () {
                if (!state.safeBlob) {
                    return;
                }
                if (
                    !REAL_UPLOAD_ENABLED
                ) {
                    state.uploadAttempted =
                        false;
                    state.uploadPerformed =
                        false;
                    status.textContent =
                        LABELS.dryRun;
                    return;
                }
                const filename =
                    normalizedFilename(
                        filenameInput.value
                    );
                if (!filename) {
                    status.textContent =
                        LABELS.badName;
                    return;
                }
                state.uploadAttempted =
                    true;
                state.uploadPerformed =
                    false;
                state.uploadErrorCode =
                    '';
                state.uploadedFilename =
                    '';
                uploadButton.disabled =
                    true;
                fileInput.disabled =
                    true;
                filenameInput.disabled =
                    true;
                try {
                    const api =
                        new mw.Api();
                    const query =
                        yield api.get({
                            action:
                                'query',
                            titles:
                                'File:' +
                                filename,
                            prop:
                                'imageinfo',
                            iiprop:
                                'url|size|sha1',
                            formatversion:
                                2
                        });
                    const page =
                        query &&
                        query.query &&
                        query.query.pages &&
                        query.query.pages[0]
                            ? query.query.pages[0]
                            : {};
                    if (
                        page.missing !==
                        true
                    ) {
                        state.uploadErrorCode =
                            'FILE_ALREADY_EXISTS';
                        status.textContent =
                            LABELS.existingFile;
                        return;
                    }
                    const result =
                        yield new Promise(
                            function (
                                resolve,
                                reject
                            ) {
                                api.upload(
                                    state.safeBlob,
                                    {
                                        filename:
                                            filename,
                                        comment:
                                            'Uploaded via MatsuriWiki privacy-safe photo uploader'
                                    }
                                )
                                    .done(
                                        function (
                                            data
                                        ) {
                                            resolve(
                                                data
                                            );
                                        }
                                    )
                                    .fail(
                                        function (
                                            code,
                                            data
                                        ) {
                                            reject({
                                                code:
                                                    code,
                                                data:
                                                    data
                                            });
                                        }
                                    );
                            }
                        );
                    const upload =
                        result &&
                        result.upload
                            ? result.upload
                            : null;
                    if (
                        !upload ||
                        upload.result !==
                            'Success'
                    ) {
                        throw {
                            code:
                                'UPLOAD_NOT_SUCCESS',
                            data:
                                result
                        };
                    }
                    const uploadedFilename =
                        upload.filename ||
                        filename;
                    /*
                    * This is intentionally the
                    * only main_image write path.
                    * It is reached only after
                    * MediaWiki reports upload
                    * success.
                    */
                    mainImage.value =
                        uploadedFilename;
                    dispatchInputChange(
                        mainImage
                    );
                    state.uploadPerformed =
                        true;
                    state.uploadedFilename =
                        uploadedFilename;
                    status.textContent =
                        LABELS.uploadSuccess;
                } catch (error) {
                    state.uploadPerformed =
                        false;
                    state.uploadErrorCode =
                        error &&
                        error.code
                            ? String(
                                error.code
                            )
                            : 'UNKNOWN';
                    const warnings =
                        error && error.data && error.data.upload
                            ? error.data.upload.warnings
                            : null;
                    if (
                        warnings &&
                        Object.prototype.hasOwnProperty.call(
                            warnings, 'bad-prefix'
                        )
                    ) {
                        state.uploadErrorCode = 'bad-prefix';
                        status.textContent =
                            '内容が分かる公開ファイル名に変更してください';
                    } else {
                        status.textContent =
                            LABELS.uploadFailure +
                            ' [' +
                            state.uploadErrorCode +
                            ']';
                    }
                } finally {
                    uploadButton.disabled =
                        !state.safeBlob;
                    fileInput.disabled =
                        false;
                    filenameInput.disabled =
                        false;
                }
                    }())
                );
            }
        );
        console.log({
            R9_3_PRIVACY_UPLOADER_READY:
                true,
            realUploadEnabled:
                REAL_UPLOAD_ENABLED,
            standardUploadHidden:
                uploadLink.hidden,
            mainImagePreserved:
                mainImage.value ===
                originalState.mainImage,
            positionStatusPreserved:
                positionStatus.value ===
                originalState.positionStatus,
            gpsValuesPrinted:
                false
        });
    }
    mw.loader
        .using(
            'mediawiki.api'
        )
        .then(
            function () {
                if (
                    document.readyState ===
                    'loading'
                ) {
                    document.addEventListener(
                        'DOMContentLoaded',
                        initUploader,
                        {
                            once:
                                true
                        }
                    );
                } else {
                    initUploader();
                }
                if (
                    mw.hook
                ) {
                    mw.hook(
                        'wikipage.content'
                    ).add(
                        initUploader
                    );
                }
            }
        );
}());
/* =====================================
* R12-01 Stall new-form name autofill
* ===================================== */
( function () {
    'use strict';
    function initR12StallNameAutofill() {
        const canonicalSpecial =
            mw.config.get(
                'wgCanonicalSpecialPageName'
            );
        const targetName =
            String(
                mw.config.get(
                    'wgPageFormsTargetName'
                ) || ''
            ).trim();
        const form =
            document.querySelector(
                '#pfForm'
            );
        const nameField =
            form
                ? form.querySelector(
                    '[name="Stall[name]"]'
                )
                : null;
        if (
            canonicalSpecial !== 'FormEdit' ||
            targetName === '' ||
            targetName === 'Dummy title' ||
            !nameField ||
            nameField.value.trim() !== ''
        ) {
            return;
        }
        nameField.value =
            targetName;
        nameField.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );
        nameField.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }
    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            initR12StallNameAutofill,
            {
                once: true
            }
        );
    } else {
        initR12StallNameAutofill();
    }
}() );
/*
* R12-03G2 屋台種類カテゴリ絞り込み
*
* FestivalStallPlacement の屋台種類 combobox に、
* 保存されないカテゴリ絞り込み UI を追加する。
*
* - カテゴリは Cargo Stalls.category から動的取得
* - 「すべて」では通常の Page Forms autocomplete
* - カテゴリ選択時は Page Forms 標準 dependent Cargo autocomplete
* - helper field は proxy form に所属させ、#pfForm には送信しない
*/
( function () {
'use strict';
const STALL_FIELD = 'FestivalStallPlacement[stall_id]';
const TARGET_NAME = 'R12StallSearchTarget';
const CATEGORY_NAME = 'R12StallCategoryFilter';
const PROXY_FORM_ID = 'r12-stall-filter-proxy-form';
const FILTER_ID = 'r12-stall-category-filter';
const ALL_VALUE = '__all__';
const DEPENDENT_PAIR = [
CATEGORY_NAME,
TARGET_NAME
];
function isOurDependentPair( pair ) {
return (
Array.isArray( pair ) &&
pair.length >= 2 &&
pair[ 0 ] === CATEGORY_NAME &&
pair[ 1 ] === TARGET_NAME
);
}
function setDependentFilterEnabled( enabled ) {
const current =
mw.config.get( 'wgPageFormsDependentFields' ) || [];
const next = current.filter( function ( pair ) {
return !isOurDependentPair( pair );
} );
if ( enabled ) {
next.push( [
CATEGORY_NAME,
TARGET_NAME
] );
}
mw.config.set(
'wgPageFormsDependentFields',
next
);
}
function extractCategories( response ) {
const rows =
response &&
Array.isArray( response.cargoquery )
? response.cargoquery
: [];
const seen = new Set();
rows.forEach( function ( row ) {
const value =
row &&
row.title
? row.title.category
: null;
if (
typeof value === 'string' &&
value.trim() !== ''
) {
seen.add(
value.trim()
);
}
} );
return Array.from( seen );
}
function createProxyForm() {
let proxy =
document.getElementById(
PROXY_FORM_ID
);
if ( proxy ) {
return proxy;
}
proxy =
document.createElement(
'form'
);
proxy.id =
PROXY_FORM_ID;
proxy.hidden =
true;
document.body.appendChild(
proxy
);
return proxy;
}
function installFilter(
pfForm,
hidden,
span,
visible,
categories
) {
if (
document.getElementById(
FILTER_ID
)
) {
return;
}
const cell =
span.closest(
'td'
);
if ( !cell ) {
return;
}
createProxyForm();
const wrapper =
document.createElement(
'div'
);
wrapper.id =
FILTER_ID;
wrapper.style.marginBottom =
'10px';
const label =
document.createElement(
'label'
);
label.textContent =
'カテゴリで絞り込み(任意)';
label.style.display =
'block';
label.style.fontWeight =
'600';
label.style.marginBottom =
'4px';
const select =
document.createElement(
'select'
);
select.name =
CATEGORY_NAME;
select.setAttribute(
'form',
PROXY_FORM_ID
);
select.setAttribute(
'autocompletesettings',
'Stalls|category'
);
select.setAttribute(
'aria-label',
'屋台の種類をカテゴリで絞り込み'
);
select.style.width =
'100%';
select.style.maxWidth =
'100%';
select.style.boxSizing =
'border-box';
const allOption =
document.createElement(
'option'
);
allOption.value =
ALL_VALUE;
allOption.textContent =
'すべて';
select.appendChild(
allOption
);
categories.forEach( function ( category ) {
const option =
document.createElement(
'option'
);
option.value =
category;
option.textContent =
category;
select.appendChild(
option
);
} );
const help =
document.createElement(
'div'
);
help.className =
'stall-form-help';
help.textContent =
'カテゴリを選ぶと、屋台の種類の検索候補を絞り込めます。';
wrapper.appendChild(
label
);
wrapper.appendChild(
select
);
wrapper.appendChild(
help
);
/*
* Page Forms dependentOn() が
* visible combobox を識別できるようにする。
*
* form 属性を proxy form に向けるため、
* TARGET_NAME は #pfForm の FormData には入らない。
*/
visible.setAttribute(
'name',
TARGET_NAME
);
visible.setAttribute(
'form',
PROXY_FORM_ID
);
/*
* R12-03G2-R2
*
* Page Forms の Cargo remote autocomplete は、
* autocompletedatatype='cargo field' の場合、
* 空文字で dependent autocomplete に到達する前に
* 「1文字以上入力してください」で終了する。
*
* 実カテゴリが選択されているこの屋台種類欄だけ、
* setValues() 実行中に autocompletedatatype を
* 一時的に undefined にし、Page Forms 標準の
* dependent Cargo autocomplete 経路へ通す。
*
* 「すべて」では従来の Cargo autocomplete を維持する。
*/
const comboPrototype =
window.pf &&
window.pf.ComboBoxInput &&
window.pf.ComboBoxInput.prototype;
if (
comboPrototype &&
typeof comboPrototype.setValues === 'function' &&
comboPrototype.__r12StallDependentCargoBridge !== '1'
) {
const originalSetValues =
comboPrototype.setValues;
comboPrototype.setValues =
function () {
const args =
arguments;
const category =
document.querySelector(
'[name="' +
CATEGORY_NAME +
'"]'
);
const isTarget =
this.config &&
this.config.autocompletesettings ===
'Stalls|name' &&
typeof this.dependentOn ===
'function' &&
this.dependentOn() ===
CATEGORY_NAME;
const specificCategory =
category &&
category.value &&
category.value !==
ALL_VALUE;
if (
isTarget &&
specificCategory &&
this.config.autocompletedatatype ===
'cargo field'
) {
const originalDatatype =
this.config.autocompletedatatype;
try {
this.config.autocompletedatatype =
undefined;
return originalSetValues.apply(
this,
args
);
} finally {
this.config.autocompletedatatype =
originalDatatype;
}
}
return originalSetValues.apply(
this,
args
);
};
comboPrototype.__r12StallDependentCargoBridge =
'1';
}
select.addEventListener(
'change',
function () {
const filterEnabled =
select.value !==
ALL_VALUE;
setDependentFilterEnabled(
filterEnabled
);
/*
* 実カテゴリへ切り替えた場合、
* 以前のカテゴリの屋台名を検索文字列として
* dependent autocomplete に渡さない。
*
* 本物の hidden input も空にし、
* 新しいカテゴリから選び直してもらう。
*/
if ( filterEnabled ) {
hidden.value =
'';
visible.value =
'';
visible.setAttribute(
'data-value',
''
);
visible.setAttribute(
'data-label',
''
);
visible.setAttribute(
'title',
''
);
}
}
);
/*
* 初期状態は「すべて」。
*/
setDependentFilterEnabled(
false
);
cell.insertBefore(
wrapper,
span
);
span.dataset.r12StallCategoryFilter =
'1';
/*
* Safety invariant:
* 本物の stall_id hidden input は
* #pfForm に所属したまま。
*/
if (
hidden.form !== pfForm
) {
mw.log.warn(
'R12-03G2: stall_id form ownership changed unexpectedly.'
);
}
}
function initialize() {
const pfForm =
document.querySelector(
'#pfForm'
);
if ( !pfForm ) {
return false;
}
const hidden =
pfForm.querySelector(
'input[type="hidden"][name="' +
STALL_FIELD +
'"]'
);
if ( !hidden ) {
return false;
}
const span =
hidden.closest(
'.comboboxSpan'
);
if (
!span ||
span.dataset.r12StallCategoryFilter ===
'1' ||
span.dataset.r12StallCategoryFilterLoading ===
'1'
) {
return !!span;
}
const visible =
span.querySelector(
'input[role="combobox"]'
);
if (
!visible ||
visible.getAttribute(
'autocompletesettings'
) !== 'Stalls|name'
) {
return false;
}
span.dataset.r12StallCategoryFilterLoading =
'1';
mw.loader.using(
'mediawiki.api'
).then( function () {
const api =
new mw.Api();
return api.get( {
action:
'cargoquery',
tables:
'Stalls',
fields:
'category',
where:
"category IS NOT NULL AND category != ''",
group_by:
'category',
order_by:
'category',
limit:
500,
format:
'json'
} );
} ).then( function ( response ) {
const categories =
extractCategories(
response
);
if (
categories.length === 0
) {
return;
}
installFilter(
pfForm,
hidden,
span,
visible,
categories
);
} ).catch( function ( error ) {
mw.log.warn(
'R12-03G2: category filter initialization failed.',
error
);
} ).always( function () {
delete span.dataset
.r12StallCategoryFilterLoading;
} );
return true;
}
function start() {
let attempts =
0;
const maxAttempts =
50;
function tryInitialize() {
attempts +=
1;
if (
initialize() ||
attempts >=
maxAttempts
) {
return;
}
window.setTimeout(
tryInitialize,
100
);
}
tryInitialize();
}
if (
document.readyState ===
'loading'
) {
document.addEventListener(
'DOMContentLoaded',
start,
{
once:
true
}
);
} else {
start();
}
}() );
/* =========================================
* FestivalTemporaryFacility:
* 祭り → 会場候補連動
* ========================================= */
$(function () {
    function setupFestivalVenueFilter() {
        const festivalSelect =
            document.querySelector(
                'input[type="hidden"][name="FestivalTemporaryFacility[festival_id]"]'
            ) ||
            document.querySelector(
                'select[name="FestivalTemporaryFacility[festival_id]"]:not(.pfComboBox)'
            );
        const venueSelect =
            document.querySelector(
                'select[name="FestivalTemporaryFacility[venue_id]"]'
            );
        if (!festivalSelect || !venueSelect) {
            return;
        }
        if (
            venueSelect.dataset.r5FestivalVenueFilter ===
            '1'
        ) {
            return;
        }
        venueSelect.dataset.r5FestivalVenueFilter =
            '1';
        const api = new mw.Api();
        const originalOptions =
            Array.from(
                venueSelect.options
            ).map(function (option) {
                return option.cloneNode(true);
            });
        const initialFestival =
            festivalSelect.value.trim();
        const initialVenue =
            venueSelect.value.trim();
        let requestId = 0;
        function escapeCargoValue(value) {
            return String(value).replace(
                /'/g,
                "''"
            );
        }
        function getBlankOption(label) {
            let blank =
                originalOptions.find(function (option) {
                    return option.value === '';
                });
            if (blank) {
                blank=blank.cloneNode(true);
            } else {
                blank=document.createElement(
                    'option'
                );
                blank.value='';
            }
            blank.textContent=label;
            return blank;
        }
        function findOriginalOption(value) {
            const option =
                originalOptions.find(
                    function (item) {
                        return item.value === value;
                    }
                );
            if (option) {
                return option.cloneNode(true);
            }
            const dynamicOption =
                document.createElement(
                    'option'
                );
            dynamicOption.value =
                value;
            dynamicOption.textContent =
                value;
            dynamicOption.setAttribute(
                'data-r14-dynamic-venue-option',
                '1'
            );
            return dynamicOption;
        }
        function dispatchVenueChange(
            preservePlacementCoordinates
        ) {
            venueSelect.dispatchEvent(
                new CustomEvent(
                    'change',
                    {
                        bubbles: true,
                        detail: {
                            matsuriPreservePlacementCoordinates:
                                preservePlacementCoordinates === true
                        }
                    }
                )
            );
        }
        function replaceOptions(
            venuePages,
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const oldValue =
                preserveCurrent
                    ? initialVenue
                    : '';
            const fragment =
                document.createDocumentFragment();
            fragment.appendChild(
                getBlankOption('未指定')
            );
            venuePages.forEach(function (page) {
                let option =
                    findOriginalOption(page);
                if (!option) {
                    console.warn(
                        'Page Formsの元候補に会場がありません。',
                        page
                    );
                    return;
                }
                option.selected=false;
                fragment.appendChild(option);
            });
            if (
                preserveCurrent &&
                oldValue !== '' &&
                !venuePages.includes(oldValue)
            ) {
                const currentOption =
                    findOriginalOption(oldValue);
                if (currentOption) {
                    currentOption.textContent +=
                        '(現在登録値)';
                    fragment.appendChild(
                        currentOption
                    );
                }
            }
            venueSelect.replaceChildren(
                fragment
            );
            let nextValue='';
            if (
                preserveCurrent &&
                oldValue !== '' &&
                Array.from(
                    venueSelect.options
                ).some(function (option) {
                    return option.value ===
                        oldValue;
                })
            ) {
                nextValue=oldValue;
            }
            venueSelect.value=nextValue;
            venueSelect.disabled=false;
            dispatchVenueChange(
                preservePlacementCoordinates
            );
        }
        function showLoading() {
            venueSelect.replaceChildren(
                getBlankOption(
                    '会場候補を読み込み中…'
                )
            );
            venueSelect.disabled=true;
        }
        function showFailure(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const fragment =
                document.createDocumentFragment();
            fragment.appendChild(
                getBlankOption(
                    '未指定(候補取得失敗)'
                )
            );
            if (
                preserveCurrent &&
                initialVenue !== ''
            ) {
                const current =
                    findOriginalOption(
                        initialVenue
                    );
                if (current) {
                    current.textContent +=
                        '(現在登録値)';
                    current.selected=true;
                    fragment.appendChild(
                        current
                    );
                }
            }
            venueSelect.replaceChildren(
                fragment
            );
            venueSelect.disabled=false;
            dispatchVenueChange(
                preservePlacementCoordinates
            );
        }
        function loadVenues(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const festivalValue =
                festivalSelect.value.trim();
            const currentRequest =
                ++requestId;
            if (festivalValue === '') {
                venueSelect.replaceChildren(
                    getBlankOption('未指定')
                );
                venueSelect.disabled=false;
                dispatchVenueChange(
                    preservePlacementCoordinates
                );
                return;
            }
            showLoading();
            const escaped =
                escapeCargoValue(
                    festivalValue
                );
            api.get({
                action:'cargoquery',
                format:'json',
                tables:
                    'Festivals=F,' +
                    'FestivalVenues=FV,' +
                    'Venues=V',
                fields:
                    'V._pageName=venue_page,' +
                    'V.name=venue_name,' +
                    'FV.sort_order=sort_order',
                join_on:
                    'F.festival_id=FV.festival_id,' +
                    'FV.venue_id=V.venue_id',
                where:
                    "(" +
                    "F.name='" +
                    escaped +
                    "' OR " +
                    "F._pageName='" +
                    escaped +
                    "'" +
                    ")",
                order_by:
                    'FV.sort_order ASC,' +
                    'V.name ASC',
                limit:'100'
            }).then(function (data) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }
                const rows =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];
                const venuePages=[];
                rows.forEach(function (result) {
                    const row =
                        result.title ||
                        result;
                    const page =
                        row.venue_page ===
                            undefined ||
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();
                    if (
                        page !== '' &&
                        !venuePages.includes(page)
                    ) {
                        venuePages.push(page);
                    }
                });
                replaceOptions(
                    venuePages,
                    preserveCurrent,
                    preservePlacementCoordinates
                );
                console.log(
                    '祭り連動会場候補を更新しました。',
                    {
                        festival:
                            festivalValue,
                        venues:
                            venuePages
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }
                console.error(
                    '祭り連動会場候補の取得に失敗しました。',
                    error
                );
                showFailure(
                    preserveCurrent,
                    preservePlacementCoordinates
                );
            });
        }
        festivalSelect.addEventListener(
            'change',
            function () {
                loadVenues(
                    false,
                    false
                );
            }
        );
        loadVenues(
            festivalSelect.value.trim() ===
                initialFestival &&
            initialVenue !== '',
            true
        );
    }
    var festivalVenueFilterRetryTimer =
        null;
    function startFestivalVenueFilterSetup() {
        var attempts = 0;
        var maxAttempts = 50;
        if (
            !document.querySelector(
                'select[name="FestivalTemporaryFacility[venue_id]"]'
            )
        ) {
            return;
        }
        if (
            festivalVenueFilterRetryTimer !==
            null
        ) {
            return;
        }
        function trySetup() {
            var venueSelect;
            festivalVenueFilterRetryTimer =
                null;
            setupFestivalVenueFilter();
            venueSelect =
                document.querySelector(
                    'select[name="FestivalTemporaryFacility[venue_id]"]'
                );
            if (
                venueSelect &&
                venueSelect.getAttribute(
                    'data-r5-festival-venue-filter'
                ) === '1'
            ) {
                return;
            }
            attempts += 1;
            if (attempts >= maxAttempts) {
                console.warn(
                    '[R14-02] FestivalTemporaryFacility ' +
                    'festival/venue filter initialization timed out.'
                );
                return;
            }
            festivalVenueFilterRetryTimer =
                window.setTimeout(
                    trySetup,
                    100
                );
        }
        trySetup();
    }
    startFestivalVenueFilterSetup();
    mw.hook(
        'pf.formSetupAfter'
    ).add(
        startFestivalVenueFilterSetup
    );
});
/* =========================================
* FestivalTemporaryFacility:
* 会場連動地図ピン → 緯度・経度
* ========================================= */
$(function () {
    const venueSelect = document.querySelector(
        'select[name="FestivalTemporaryFacility[venue_id]"]'
    );
    const latInput = document.querySelector(
        'input[name="FestivalTemporaryFacility[latitude]"]'
    );
    const lonInput = document.querySelector(
        'input[name="FestivalTemporaryFacility[longitude]"]'
    );
    if (
        !venueSelect ||
        !latInput ||
        !lonInput
    ) {
        return;
    }
    mw.loader.using(
        'ext.pageforms.leaflet'
    ).then(function () {
        if (
            document.getElementById(
                'matsuri-temporary-facility-location-map'
            )
        ) {
            return;
        }
        const api = new mw.Api();
        const mapDiv =
            document.createElement('div');
        mapDiv.id =
            'matsuri-temporary-facility-location-map';
        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';
        const help =
            document.createElement('div');
        help.textContent =
            '会場を選択すると会場周辺を表示します。' +
            '地図をクリックして実際の臨時設備位置を指定してください。' +
            'ピンはドラッグして微調整できます。';
        help.style.marginBottom = '8px';
        const wrapper =
            document.createElement('div');
        wrapper.appendChild(help);
        wrapper.appendChild(mapDiv);
        const latRow =
            latInput.closest('tr');
        if (
            !latRow ||
            !latRow.parentNode
        ) {
            return;
        }
        const mapRow =
            document.createElement('tr');
        const th =
            document.createElement('th');
        th.textContent =
            '臨時設備の位置を地図から選択';
        const td =
            document.createElement('td');
        td.appendChild(wrapper);
        mapRow.appendChild(th);
        mapRow.appendChild(td);
        latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );
        /*
        * 初期状態は日本全体。
        *
        * 既存Placementに座標がある場合は
        * 後でその位置へ移動する。
        */
        const map = L.map(
            mapDiv
        ).setView(
            [ 36.2048, 138.2529 ],
            5
        );
        L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);
        let marker = null;
        let venueRequestId = 0;
        const venueStatus =
            document.createElement(
                'div'
            );
        venueStatus.className =
            'matsuri-venue-location-status';
        venueStatus.setAttribute(
            'aria-live',
            'polite'
        );
        venueStatus.style.marginBottom =
            '8px';
        if (mapDiv.parentNode) {
            mapDiv.parentNode.insertBefore(
                venueStatus,
                mapDiv
            );
        }
        function setVenueStatus(message) {
            venueStatus.textContent =
                message;
        }
        function resetVenueView() {
            map.setView(
                [ 36.2048, 138.2529 ],
                5
            );
        }
        function dispatchInputEvents(input) {
            input.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );
            input.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );
        }
        function updateInputs(lat, lon) {
            latInput.value =
                Number(lat).toFixed(6);
            lonInput.value =
                Number(lon).toFixed(6);
            /*
            * 既存の必須・日本範囲チェックを
            * そのまま発火させる。
            */
            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }
        /*
        * R10-5C ISSUE-06B:
        * PageForms配下のLeaflet default PNGは
        * この環境ではHTMLへredirectされるため、
        * 外部画像に依存しないdivIconを使用。
        */
        const placementMarkerIcon =
            L.divIcon({
                className:
                    'matsuri-temporary-facility-marker-icon',
                html:
                    '<svg xmlns="http://www.w3.org/2000/svg" ' +
                    'width="28" height="42" viewBox="0 0 28 42" ' +
                    'aria-hidden="true" focusable="false">' +
                    '<path d="M14 1C6.8 1 1 6.8 1 14c0 10 13 27 13 27s13-17 13-27C27 6.8 21.2 1 14 1Z" ' +
                    'fill="#2a81cb" stroke="#ffffff" stroke-width="2"/>' +
                    '<circle cx="14" cy="14" r="5" fill="#ffffff"/>' +
                    '</svg>',
                iconSize:
                    [
                        28,
                        42
                    ],
                iconAnchor:
                    [
                        14,
                        40
                    ]
            });
        function createMarker(latlng) {
            marker = L.marker(
                latlng,
                {
                    draggable:
                        true,
                    icon:
                        placementMarkerIcon
                }
            ).addTo(map);
            marker.on(
                'dragend',
                function () {
                    const position =
                        marker.getLatLng();
                    updateInputs(
                        position.lat,
                        position.lng
                    );
                }
            );
        }
        function placeMarker(latlng) {
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                createMarker(latlng);
            }
            updateInputs(
                latlng.lat,
                latlng.lng
            );
        }
        function removeMarker() {
            if (!marker) {
                return;
            }
            map.removeLayer(marker);
            marker = null;
        }
        function clearCoordinates() {
            latInput.value = '';
            lonInput.value = '';
            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }
        function getCurrentCoordinates() {
            const lat =
                Number(latInput.value);
            const lon =
                Number(lonInput.value);
            if (
                latInput.value.trim() === '' ||
                lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return null;
            }
            return {
                lat: lat,
                lng: lon
            };
        }
        function escapeCargoValue(value) {
            return String(value)
                .replace(
                    /'/g,
                    "''"
                );
        }
        /*
        * 選択されたVenueの座標へ
        * 地図だけ移動する。
        *
        * Placementのlatitude/longitudeには
        * コピーしない。
        */
        function centerOnVenue() {
            const currentRequest =
                ++venueRequestId;
            const venuePage =
                venueSelect.value.trim();
            if (venuePage === '') {
                resetVenueView();
                setVenueStatus(
                    '会場は未指定です。' +
                    '地図上で場所を指定できます。'
                );
                return;
            }
            setVenueStatus(
                '選択した会場の位置情報を確認しています。'
            );
            api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Venues',
                fields:
                    'venue_id=venue_id,' +
                    '_pageName=page_name,' +
                    'latitude=latitude,' +
                    'longitude=longitude',
                where:
                    "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
            }).then(function (data) {
                /*
                * 連続して会場を変更した場合、
                * 古いレスポンスを無視する。
                */
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }
                const result =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];
                if (result.length === 0) {
                    resetVenueView();
                    setVenueStatus(
                        '会場情報を取得できませんでした。' +
                        '地図上で場所を指定できます。'
                    );
                    console.warn(
                        '会場情報を取得できませんでした。',
                        venuePage
                    );
                    return;
                }
                const row =
                    result[0].title ||
                    result[0];
                const lat =
                    Number(row.latitude);
                const lon =
                    Number(row.longitude);
                if (
                    row.latitude === undefined ||
                    row.latitude === null ||
                    String(
                        row.latitude
                    ).trim() === '' ||
                    row.longitude === undefined ||
                    row.longitude === null ||
                    String(
                        row.longitude
                    ).trim() === '' ||
                    Number.isNaN(lat) ||
                    Number.isNaN(lon)
                ) {
                    resetVenueView();
                    setVenueStatus(
                        'この会場は位置情報未登録です。' +
                        '地図上で場所を指定できます。'
                    );
                    console.warn(
                        '選択した会場には座標が登録されていません。',
                        venuePage
                    );
                    return;
                }
                map.setView(
                    [ lat, lon ],
                    18
                );
                setVenueStatus(
                    '選択した会場の位置を表示しています。' +
                    '必要に応じて地図上で実際の位置を指定してください。'
                );
                console.log(
                    '会場位置へ地図を移動しました。',
                    {
                        venue: venuePage,
                        latitude: lat,
                        longitude: lon
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }
                resetVenueView();
                setVenueStatus(
                    '会場位置の取得に失敗しました。' +
                    '地図上で場所を指定できます。'
                );
                console.error(
                    '会場座標の取得に失敗しました。',
                    error
                );
            });
        }
        /*
        * 地図クリック
        */
        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
        );
        /*
        * 手入力された場合もピンを同期。
        */
        function syncMarkerFromInputs() {
            const coordinates =
                getCurrentCoordinates();
            if (!coordinates) {
                return;
            }
            /*
            * User-selected / manually-entered coordinates
            * take priority over a late Venue response.
            */
            venueRequestId += 1;
            setVenueStatus(
                '指定した位置を地図に表示しています。'
            );
            if (marker) {
                marker.setLatLng(
                    coordinates
                );
            } else {
                createMarker(
                    coordinates
                );
            }
            map.setView(
                [
                    coordinates.lat,
                    coordinates.lng
                ],
                18
            );
        }
        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );
        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );
        /*
        * 会場を変更した場合。
        *
        * 前の会場用の屋台座標を
        * 誤って残さないようクリアする。
        */
        venueSelect.addEventListener(
            'change',
            function (event) {
                const preservePlacementCoordinates =
                    !!(
                        event &&
                        event.detail &&
                        event.detail
                            .matsuriPreservePlacementCoordinates ===
                            true
                    );
                /*
                * Even when the new Venue is blank,
                * invalidate an older Cargo response.
                */
                venueRequestId += 1;
                if (
                    preservePlacementCoordinates
                ) {
                    /*
                    * Existing Placement coordinates
                    * take priority over Venue center.
                    *
                    * If there are no Placement
                    * coordinates, Venue is still a
                    * useful map starting point.
                    */
                    if (
                        !getCurrentCoordinates()
                    ) {
                        centerOnVenue();
                    }
                    return;
                }
                removeMarker();
                clearCoordinates();
                centerOnVenue();
            }
        );
        /*
        * 編集時:
        * 既存Placement座標を優先。
        *
        * 新規時:
        * Venue座標へ地図を移動。
        */
        const initialCoordinates =
            getCurrentCoordinates();
        if (initialCoordinates) {
            createMarker(
                initialCoordinates
            );
            setVenueStatus(
                '登録済みの位置を地図に表示しています。'
            );
            map.setView(
                [
                    initialCoordinates.lat,
                    initialCoordinates.lng
                ],
                18
            );
        } else {
            centerOnVenue();
        }
        /*
        * R10-5C ISSUE-06A:
        * 折りたたみ中はmapが0x0なので、
        * 実際に展開された後でも
        * Leafletの内部サイズを再計算する。
        */
        const mapCollapsible =
            mapDiv.closest(
                '.mw-collapsible'
            );
        if (
            mapCollapsible
        ) {
            $(
                mapCollapsible
            ).on(
                'afterExpand.mw-collapsible',
                function () {
                    setTimeout(
                        function () {
                            map.invalidateSize();
                        },
                        0
                    );
                }
            );
        }
        /*
        * 初期状態ですでに表示されている
        * ケースの既存挙動も維持。
        */
        setTimeout(
            function () {
                map.invalidateSize();
            },
            100
        );
        console.log(
            '臨時設備位置地図ピン入力を初期化しました。'
        );
    });
});
/* === R13-01 Venue地域かな検索 START === */
/*
* Form:Venue 地域欄:
* 漢字検索は Page Forms 標準。
* ひらがな・カタカナ入力時は Areas.kana も検索する。
*
* ResourceLoader互換:
* ?? / ?. / async / await / arrow function は使用しない。
*/
( function () {
    'use strict';
    var INSTALLED_ATTR =
        'data-r13-venue-kana-autocomplete';
    function toHiragana( value ) {
        return String( value || '' )
            .normalize( 'NFKC' )
            .replace(
                /[ァ-ヶ]/g,
                function ( ch ) {
                    return String.fromCharCode(
                        ch.charCodeAt( 0 ) - 0x60
                    );
                }
            )
            .replace( /\s+/g, '' )
            .trim();
    }
    function isKana( value ) {
        return /^[ぁ-ゖー]+$/.test( value );
    }
    function firstValue( obj, keys ) {
        var i;
        var key;
        var value;
        for ( i = 0; i < keys.length; i++ ) {
            key = keys[i];
            value = obj[key];
            if (
                typeof value !== 'undefined' &&
                value !== null &&
                value !== ''
            ) {
                return value;
            }
        }
        return '';
    }
    function normalizeRow( raw ) {
        return {
            areaId: firstValue(
                raw,
                [ 'area_id', 'area id', 'areaId' ]
            ),
            name: firstValue(
                raw,
                [ 'name', 'Name' ]
            ),
            kana: firstValue(
                raw,
                [ 'kana', 'Kana' ]
            ),
            areaType: firstValue(
                raw,
                [ 'area_type', 'area type', 'areaType' ]
            )
        };
    }
    function areaTypeLabel( type ) {
        var labels = {
            prefecture: '都道府県',
            city: '市',
            special_ward: '特別区',
            ward: '行政区',
            town: '町',
            village: '村'
        };
        return labels[type] || type || '';
    }
    function findVenueAreaInput() {
        var inputs =
            document.querySelectorAll(
                'input[role="combobox"]'
            );
        var i;
        var input;
        var span;
        var hidden;
        for ( i = 0; i < inputs.length; i++ ) {
            input = inputs[i];
            if (
                input.getAttribute(
                    'autocompletesettings'
                ) !== 'Areas|name'
            ) {
                continue;
            }
            span =
                input.closest(
                    '.comboboxSpan'
                );
            if ( !span ) {
                continue;
            }
            hidden =
                span.querySelector(
                    'input[type="hidden"]' +
                    '[name="Venue[area_id]"]'
                );
            if ( hidden ) {
                return {
                    input: input,
                    hidden: hidden
                };
            }
        }
        return null;
    }
    function install() {
        var pair =
            findVenueAreaInput();
        if ( !pair ) {
            return false;
        }
        var input =
            pair.input;
        var hidden =
            pair.hidden;
        if (
            input.getAttribute(
                INSTALLED_ATTR
            ) === '1'
        ) {
            return true;
        }
        input.setAttribute(
            INSTALLED_ATTR,
            '1'
        );
        var api =
            new mw.Api();
        var box =
            document.createElement(
                'div'
            );
        box.className =
            'r13-venue-kana-results';
        box.style.position =
            'absolute';
        box.style.zIndex =
            '1000000';
        box.style.background =
            '#fff';
        box.style.border =
            '1px solid #a2a9b1';
        box.style.borderRadius =
            '2px';
        box.style.boxShadow =
            '0 2px 6px rgba(0,0,0,.2)';
        box.style.maxHeight =
            '300px';
        box.style.overflowY =
            'auto';
        box.style.display =
            'none';
        box.style.boxSizing =
            'border-box';
        document.body.appendChild(
            box
        );
        var timer = null;
        var composing = false;
        var requestSeq = 0;
        function positionBox() {
            var rect =
                input.getBoundingClientRect();
            box.style.left =
                (
                    window.scrollX +
                    rect.left
                ) + 'px';
            box.style.top =
                (
                    window.scrollY +
                    rect.bottom +
                    2
                ) + 'px';
            box.style.width =
                Math.max(
                    rect.width,
                    220
                ) + 'px';
        }
        function clearBox() {
            while (
                box.firstChild
            ) {
                box.removeChild(
                    box.firstChild
                );
            }
        }
        function hideBox() {
            box.style.display =
                'none';
            clearBox();
        }
        function selectArea( row ) {
            requestSeq++;
            /*
            * Page Forms側ではnameを保持し、
            * フォーム送信時にarea_idへmapping。
            */
            input.value =
                row.name;
            hidden.value =
                row.name;
            input.setAttribute(
                'data-value',
                row.name
            );
            input.setAttribute(
                'data-label',
                row.name
            );
            input.setAttribute(
                'data-string-type',
                'value'
            );
            input.title =
                row.name;
            hideBox();
            input.dispatchEvent(
                new Event(
                    'change',
                    {
                        bubbles: true
                    }
                )
            );
            /*
            * Page Formsのchange処理後も
            * nameを確定。
            */
            input.value =
                row.name;
            hidden.value =
                row.name;
        }
        function render(
            rows,
            query
        ) {
            var i;
            var row;
            var item;
            var name;
            var meta;
            clearBox();
            if (
                rows.length === 0
            ) {
                var empty =
                    document.createElement(
                        'div'
                    );
                empty.textContent =
                    '読み仮名に一致する地域がありません';
                empty.style.padding =
                    '8px 10px';
                empty.style.color =
                    '#54595d';
                box.appendChild(
                    empty
                );
                positionBox();
                box.style.display =
                    'block';
                return;
            }
            rows.sort(
                function ( a, b ) {
                    var ak =
                        toHiragana(
                            a.kana
                        );
                    var bk =
                        toHiragana(
                            b.kana
                        );
                    var ar =
                        ak.indexOf(
                            query
                        ) === 0
                            ? 0
                            : 1;
                    var br =
                        bk.indexOf(
                            query
                        ) === 0
                            ? 0
                            : 1;
                    if (
                        ar !== br
                    ) {
                        return ar - br;
                    }
                    if (
                        ak.length !==
                        bk.length
                    ) {
                        return (
                            ak.length -
                            bk.length
                        );
                    }
                    return a.name.localeCompare(
                        b.name,
                        'ja'
                    );
                }
            );
            rows =
                rows.slice(
                    0,
                    25
                );
            for (
                i = 0;
                i < rows.length;
                i++
            ) {
                row =
                    rows[i];
                item =
                    document.createElement(
                        'button'
                    );
                item.type =
                    'button';
                item.style.display =
                    'block';
                item.style.width =
                    '100%';
                item.style.border =
                    '0';
                item.style.borderBottom =
                    '1px solid #eaecf0';
                item.style.background =
                    '#fff';
                item.style.padding =
                    '7px 10px';
                item.style.textAlign =
                    'left';
                item.style.cursor =
                    'pointer';
                item.style.font =
                    'inherit';
                name =
                    document.createElement(
                        'div'
                    );
                name.textContent =
                    row.name;
                name.style.fontWeight =
                    '600';
                name.style.color =
                    '#202122';
                meta =
                    document.createElement(
                        'div'
                    );
                meta.textContent =
                    row.kana +
                    (
                        row.areaType
                            ? ' ・ ' +
                              areaTypeLabel(
                                  row.areaType
                              )
                            : ''
                    );
                meta.style.marginTop =
                    '2px';
                meta.style.fontSize =
                    '11px';
                meta.style.color =
                    '#72777d';
                item.appendChild(
                    name
                );
                item.appendChild(
                    meta
                );
                ( function (
                    button,
                    area
                ) {
                    button.addEventListener(
                        'mouseenter',
                        function () {
                            button.style.background =
                                '#eaecf0';
                        }
                    );
                    button.addEventListener(
                        'mouseleave',
                        function () {
                            button.style.background =
                                '#fff';
                        }
                    );
                    button.addEventListener(
                        'mousedown',
                        function ( event ) {
                            event.preventDefault();
                        }
                    );
                    button.addEventListener(
                        'click',
                        function () {
                            selectArea(
                                area
                            );
                        }
                    );
                }(
                    item,
                    row
                ) );
                box.appendChild(
                    item
                );
            }
            positionBox();
            box.style.display =
                'block';
        }
        function search() {
            var query =
                toHiragana(
                    input.value
                );
            /*
            * かな2文字以上だけ追加検索。
            * それ以外はPage Forms標準へ任せる。
            */
            if (
                query.length < 2 ||
                !isKana(
                    query
                )
            ) {
                requestSeq++;
                hideBox();
                return;
            }
            var seq =
                ++requestSeq;
            var escaped =
                query.replace(
                    /'/g,
                    "''"
                );
            api.get(
                {
                    action:
                        'cargoquery',
                    format:
                        'json',
                    tables:
                        'Areas',
                    fields:
                        'area_id,name,kana,area_type',
                    where:
                        "kana LIKE '%" +
                        escaped +
                        "%'",
                    limit:
                        200
                }
            )
                .done(
                    function (
                        result
                    ) {
                        var rawRows =
                            result.cargoquery ||
                            [];
                        var rows = [];
                        var seen = {};
                        var i;
                        var raw;
                        var row;
                        var key;
                        if (
                            seq !==
                            requestSeq
                        ) {
                            return;
                        }
                        for (
                            i = 0;
                            i <
                            rawRows.length;
                            i++
                        ) {
                            raw =
                                rawRows[i].title ||
                                rawRows[i];
                            row =
                                normalizeRow(
                                    raw
                                );
                            if (
                                !row.areaId ||
                                !row.name ||
                                !row.kana
                            ) {
                                continue;
                            }
                            key =
                                String(
                                    row.areaId
                                );
                            if (
                                seen[key]
                            ) {
                                continue;
                            }
                            seen[key] =
                                true;
                            rows.push(
                                row
                            );
                        }
                        render(
                            rows,
                            query
                        );
                    }
                )
                .fail(
                    function (
                        code,
                        details
                    ) {
                        if (
                            seq !==
                            requestSeq
                        ) {
                            return;
                        }
                        console.error(
                            '[R13-01 VenueKana]',
                            code,
                            details
                        );
                        hideBox();
                    }
                );
        }
        function schedule() {
            if (
                composing
            ) {
                return;
            }
            clearTimeout(
                timer
            );
            timer =
                setTimeout(
                    search,
                    180
                );
        }
        input.addEventListener(
            'compositionstart',
            function () {
                composing =
                    true;
            }
        );
        input.addEventListener(
            'compositionend',
            function () {
                composing =
                    false;
                schedule();
            }
        );
        input.addEventListener(
            'input',
            schedule
        );
        document.addEventListener(
            'mousedown',
            function (
                event
            ) {
                if (
                    event.target !==
                        input &&
                    !box.contains(
                        event.target
                    )
                ) {
                    hideBox();
                }
            },
            true
        );
        window.addEventListener(
            'resize',
            function () {
                if (
                    box.style.display !==
                    'none'
                ) {
                    positionBox();
                }
            }
        );
        window.addEventListener(
            'scroll',
            function () {
                if (
                    box.style.display !==
                    'none'
                ) {
                    positionBox();
                }
            },
            true
        );
        return true;
    }
    function start() {
        var attempts =
            0;
        var installTimer =
            window.setInterval(
                function () {
                    attempts++;
                    if (
                        install() ||
                        attempts >= 40
                    ) {
                        window.clearInterval(
                            installTimer
                        );
                    }
                },
                250
            );
    }
    function ready() {
        if (
            document.readyState ===
            'loading'
        ) {
            document.addEventListener(
                'DOMContentLoaded',
                start,
                {
                    once: true
                }
            );
        } else {
            start();
        }
    }
    mw.loader.using(
        'mediawiki.api',
        ready,
        function ( error ) {
            console.error(
                '[R13-01 VenueKana load]',
                error
            );
        }
    );
}() );
/* === R13-01 Venue地域かな検索 END === */
/* === R14-02 Festival combobox hidden change bridge START === */
$(function () {
    function bindFestivalCombobox(fieldName) {
        var hidden =
            document.querySelector(
                'input[type="hidden"][name="' +
                    fieldName +
                    '"]'
            );
        if (!hidden) {
            return false;
        }
        if (
            hidden.getAttribute(
                'data-r14-festival-combo-bridge'
            ) === '1'
        ) {
            return true;
        }
        var span =
            $(hidden).closest(
                '.comboboxSpan'
            )[0];
        if (!span) {
            return false;
        }
        var visible =
            span.querySelector(
                'input:not([type="hidden"])'
            );
        if (!visible) {
            return false;
        }
        hidden.setAttribute(
            'data-r14-festival-combo-bridge',
            '1'
        );
        var lastValue =
            String(hidden.value || '');
        var timer = null;
        function dispatchHiddenChange() {
            var event =
                document.createEvent(
                    'HTMLEvents'
                );
            event.initEvent(
                'change',
                true,
                false
            );
            hidden.dispatchEvent(
                event
            );
        }
        function checkHiddenValue() {
            if (timer !== null) {
                window.clearTimeout(
                    timer
                );
            }
            timer =
                window.setTimeout(
                    function () {
                        timer = null;
                        var nextValue =
                            String(
                                hidden.value ||
                                ''
                            );
                        if (
                            nextValue ===
                            lastValue
                        ) {
                            return;
                        }
                        lastValue =
                            nextValue;
                        dispatchHiddenChange();
                    },
                    0
                );
        }
        visible.addEventListener(
            'input',
            checkHiddenValue
        );
        visible.addEventListener(
            'change',
            checkHiddenValue
        );
        visible.addEventListener(
            'blur',
            checkHiddenValue
        );
        span.addEventListener(
            'mouseup',
            checkHiddenValue
        );
        span.addEventListener(
            'keyup',
            checkHiddenValue
        );
        return true;
    }
    function retryBindFestivalCombobox(
        fieldName
    ) {
        var rawCombobox =
            document.querySelector(
                'select.pfComboBox[name="' +
                    fieldName +
                    '"]'
            );
        var hidden =
            document.querySelector(
                'input[type="hidden"][name="' +
                    fieldName +
                    '"]'
            );
        /*
        * Do not start retry timers on unrelated pages
        * or on fields that are still ordinary dropdowns.
        */
        if (!rawCombobox && !hidden) {
            return;
        }
        var attempts = 0;
        var maxAttempts = 50;
        function tryBind() {
            if (
                bindFestivalCombobox(
                    fieldName
                )
            ) {
                return;
            }
            attempts += 1;
            if (attempts >= maxAttempts) {
                console.warn(
                    '[R14-02] Festival combobox ' +
                    'bridge initialization timed out.',
                    fieldName
                );
                return;
            }
            window.setTimeout(
                tryBind,
                100
            );
        }
        tryBind();
    }
    function setupFestivalComboboxBridges() {
        retryBindFestivalCombobox(
            'FestivalStallPlacement[festival_id]'
        );
        retryBindFestivalCombobox(
            'FestivalTemporaryFacility[festival_id]'
        );
    }
    setupFestivalComboboxBridges();
    mw.hook(
        'pf.formSetupAfter'
    ).add(
        setupFestivalComboboxBridges
    );
});
/* === R14-02 Festival combobox hidden change bridge END === */
/* === R16 Entity duplicate candidate checker START === */
/*
* Festival / Venue / Stall の名称入力時に、
* Cargoの既存レコードから重複候補を表示する。
*
* 強い候補がある場合は、候補確認チェックを行うまで
* wpSaveだけを停止する。プレビューと差分確認は利用可能。
*/
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';
    var INSTALLED_ATTR =
        'data-r16-entity-duplicate';
    var configs = [
        {
            template: 'Stall',
            selector: '[name="Stall[name]"]',
            table: 'Stalls',
            fields:
                '_pageName=page_name,' +
                'name=name,' +
                'category=detail',
            entityLabel: '屋台',
            detailLabel: 'カテゴリ',
            hasKana: false
        },
        {
            template: 'Venue',
            selector: '[name="Venue[name]"]',
            table: 'Venues',
            fields:
                '_pageName=page_name,' +
                'name=name,' +
                'kana=kana,' +
                'address=detail',
            entityLabel: '会場',
            detailLabel: '住所',
            hasKana: true
        },
        {
            template: 'Festival',
            selector: '[name="Festival[name]"]',
            table: 'Festivals',
            fields:
                '_pageName=page_name,' +
                'name=name,' +
                'kana=kana,' +
                'organizer=detail',
            entityLabel: '祭り',
            detailLabel: '主催',
            hasKana: true
        }
    ];
    function normalizeSearch(value) {
        return String(value || '')
            .normalize('NFKC')
            .replace(
                /[ァ-ヶ]/g,
                function (character) {
                    return String.fromCharCode(
                        character.charCodeAt(0) -
                        0x60
                    );
                }
            )
            .toLowerCase()
            .replace(
                /[\s\u3000・・//()()[\]【】「」『』\-‐‑‒–—―]+/g,
                ''
            );
    }
    function normalizePageName(value) {
        return String(value || '')
            .normalize('NFKC')
            .replace(/_/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }
    function levenshtein(left, right) {
        var previous = [];
        var current;
        var i;
        var j;
        var cost;
        for (j = 0; j <= right.length; j++) {
            previous[j] = j;
        }
        for (i = 1; i <= left.length; i++) {
            current = [i];
            for (j = 1; j <= right.length; j++) {
                cost = (
                    left.charAt(i - 1) ===
                    right.charAt(j - 1)
                ) ? 0 : 1;
                current[j] = Math.min(
                    current[j - 1] + 1,
                    previous[j] + 1,
                    previous[j - 1] + cost
                );
            }
            previous = current;
        }
        return previous[right.length];
    }
    function bigrams(value) {
        var result = [];
        var i;
        if (value.length < 2) {
            return value ? [value] : [];
        }
        for (i = 0; i < value.length - 1; i++) {
            result.push(
                value.slice(i, i + 2)
            );
        }
        return result;
    }
    function dice(left, right) {
        var leftParts = bigrams(left);
        var rightParts = bigrams(right);
        var used = {};
        var common = 0;
        var i;
        var j;
        if (
            leftParts.length === 0 ||
            rightParts.length === 0
        ) {
            return 0;
        }
        for (i = 0; i < leftParts.length; i++) {
            for (
                j = 0;
                j < rightParts.length;
                j++
            ) {
                if (
                    !used[j] &&
                    leftParts[i] === rightParts[j]
                ) {
                    used[j] = true;
                    common++;
                    break;
                }
            }
        }
        return (
            2 * common /
            (
                leftParts.length +
                rightParts.length
            )
        );
    }
    function compare(query, candidate) {
        var ratio;
        var distance;
        var editScore;
        var diceScore;
        if (!query || !candidate) {
            return {
                score: 0,
                reason: ''
            };
        }
        if (query === candidate) {
            return {
                score: 1,
                reason: '完全一致'
            };
        }
        /*
        * 入力より短い一般語は低く評価する。
        * 例:「やきそば」に対する「そば」。
        */
        if (query.indexOf(candidate) !== -1) {
            ratio =
                candidate.length / query.length;
            return {
                score:
                    0.45 + 0.25 * ratio,
                reason: '短い名称を含む'
            };
        }
        if (candidate.indexOf(query) !== -1) {
            ratio =
                query.length / candidate.length;
            return {
                score:
                    0.65 + 0.25 * ratio,
                reason: '名称の一部が一致'
            };
        }
        distance = levenshtein(
            query,
            candidate
        );
        editScore = 1 - (
            distance /
            Math.max(
                query.length,
                candidate.length
            )
        );
        diceScore = dice(
            query,
            candidate
        );
        if (editScore >= diceScore) {
            return {
                score: Math.max(
                    0,
                    editScore
                ),
                reason:
                    '表記が近い(差' +
                    distance +
                    '文字)'
            };
        }
        return {
            score: diceScore,
            reason: '共通する文字列あり'
        };
    }
    function findConfig(root) {
        var i;
        var input;
        for (i = 0; i < configs.length; i++) {
            input = root.querySelector(
                configs[i].selector
            );
            if (input) {
                return {
                    config: configs[i],
                    input: input
                };
            }
        }
        return null;
    }
    function currentTarget(config) {
        var pageName = String(
            mw.config.get('wgPageName') || ''
        ).replace(/_/g, ' ');
        var parts = pageName.split('/');
        var i;
        for (i = 0; i < parts.length; i++) {
            if (parts[i] === config.template) {
                return parts.slice(i + 1).join('/');
            }
        }
        return '';
    }
    function createTextElement(tag, className, text) {
        var element =
            document.createElement(tag);
        if (className) {
            element.className = className;
        }
        element.textContent = text;
        return element;
    }
    function setupDuplicateChecker() {
        var root =
            document.getElementById('pfForm');
        if (!root) {
            return false;
        }
        if (
            root.getAttribute(
                INSTALLED_ATTR
            ) === '1'
        ) {
            return true;
        }
        var found = findConfig(root);
        if (!found) {
            return false;
        }
        root.setAttribute(
            INSTALLED_ATTR,
            '1'
        );
        var config = found.config;
        var input = found.input;
        var target = normalizePageName(
            currentTarget(config)
        );
        var panel =
            document.createElement('div');
        panel.id =
            'r16-entity-duplicate-panel';
        panel.className =
            'stall-duplicate-warning';
        panel.setAttribute(
            'role',
            'status'
        );
        var inputCell =
            input.closest('td') ||
            input.parentNode;
        inputCell.appendChild(panel);
        var saveButton =
            root.querySelector(
                '[name="wpSave"]'
            );
        var saveWarning =
            document.createElement('div');
        saveWarning.id =
            'r16-entity-duplicate-save-warning';
        saveWarning.className =
            'stall-duplicate-warning';
        saveWarning.setAttribute(
            'role',
            'alert'
        );
        saveWarning.hidden = true;
        var saveMessage =
            createTextElement(
                'strong',
                'stall-duplicate-warning-title',
                ''
            );
        var confirmationLabel =
            document.createElement('label');
        var confirmation =
            document.createElement('input');
        confirmation.type = 'checkbox';
        confirmation.value = '1';
        confirmationLabel.appendChild(
            confirmation
        );
        confirmationLabel.appendChild(
            document.createTextNode(
                ' 候補を確認し、別の' +
                config.entityLabel +
                'として登録します。'
            )
        );
        saveWarning.appendChild(saveMessage);
        saveWarning.appendChild(
            confirmationLabel
        );
        if (saveButton) {
            var saveAnchor =
                saveButton.closest(
                    '.oo-ui-widget'
                ) || saveButton;
            if (saveAnchor.parentNode) {
                saveAnchor.parentNode.insertBefore(
                    saveWarning,
                    saveAnchor
                );
            } else {
                root.appendChild(saveWarning);
            }
        } else {
            root.appendChild(saveWarning);
        }
        var api = new mw.Api();
        var rows = [];
        var timer = null;
        var composing = false;
        var ready = false;
        var failed = false;
        var strongCandidates = [];
        var lastQuery = '';
        function setStrongTone(strong) {
            panel.style.borderColor = strong
                ? '#b32424'
                : '';
            panel.style.borderLeftColor = strong
                ? '#b32424'
                : '';
            panel.style.background = strong
                ? '#fee7e6'
                : '';
        }
        function showSimple(text) {
            panel.replaceChildren(
                createTextElement(
                    'span',
                    '',
                    text
                )
            );
            setStrongTone(false);
            strongCandidates = [];
            saveWarning.hidden = true;
        }
        function render() {
            var query =
                normalizeSearch(input.value);
            var ranked = [];
            var i;
            var row;
            var nameMatch;
            var kanaMatch;
            var selectedMatch;
            var heading;
            var description;
            var list;
            if (query !== lastQuery) {
                confirmation.checked = false;
                lastQuery = query;
            }
            if (query.length < 2) {
                showSimple(
                    '2文字以上入力すると、' +
                    '登録済みの' +
                    config.entityLabel +
                    '候補を表示します。'
                );
                return;
            }
            if (!ready) {
                if (failed) {
                    showSimple(
                        '既存候補を取得できませんでした。' +
                        '保存前に一覧ページもご確認ください。'
                    );
                } else {
                    showSimple(
                        '登録済み候補を確認しています…'
                    );
                }
                return;
            }
            for (i = 0; i < rows.length; i++) {
                row = rows[i];
                if (
                    target &&
                    normalizePageName(
                        row.pageName
                    ) === target
                ) {
                    continue;
                }
                nameMatch = compare(
                    query,
                    normalizeSearch(row.name)
                );
                kanaMatch = config.hasKana
                    ? compare(
                        query,
                        normalizeSearch(row.kana)
                    )
                    : {
                        score: 0,
                        reason: ''
                    };
                if (
                    kanaMatch.score >
                    nameMatch.score
                ) {
                    selectedMatch = {
                        score: kanaMatch.score,
                        reason:
                            'よみ:' +
                            kanaMatch.reason
                    };
                } else {
                    selectedMatch = {
                        score: nameMatch.score,
                        reason:
                            '名称:' +
                            nameMatch.reason
                    };
                }
                if (selectedMatch.score < 0.35) {
                    continue;
                }
                ranked.push({
                    pageName: row.pageName,
                    name: row.name,
                    kana: row.kana,
                    detail: row.detail,
                    score: selectedMatch.score,
                    reason: selectedMatch.reason
                });
            }
            ranked.sort(function (left, right) {
                if (right.score !== left.score) {
                    return right.score - left.score;
                }
                return left.name.localeCompare(
                    right.name,
                    'ja'
                );
            });
            ranked = ranked.slice(0, 5);
            strongCandidates = ranked.filter(
                function (candidate) {
                    return candidate.score >= 0.72;
                }
            );
            panel.replaceChildren();
            if (ranked.length === 0) {
                showSimple(
                    '似ている登録済み候補は' +
                    '見つかりませんでした。'
                );
                return;
            }
            heading = createTextElement(
                'strong',
                'stall-duplicate-warning-title',
                strongCandidates.length
                    ? '重複の可能性が高い候補があります'
                    : '似ている登録済み候補'
            );
            description = createTextElement(
                'p',
                'stall-duplicate-warning-description',
                '既存ページを確認し、同じ対象なら' +
                '新規登録せず既存ページを編集してください。'
            );
            list = document.createElement('ul');
            list.className =
                'stall-duplicate-warning-list';
            ranked.forEach(function (candidate) {
                var item =
                    document.createElement('li');
                var link =
                    document.createElement('a');
                var details = [];
                link.href = mw.util.getUrl(
                    candidate.pageName
                );
                link.target = '_blank';
                link.rel = 'noopener';
                link.textContent = candidate.name;
                if (candidate.kana) {
                    details.push(
                        'よみ:' + candidate.kana
                    );
                }
                if (candidate.detail) {
                    details.push(
                        config.detailLabel +
                        ':' +
                        candidate.detail
                    );
                }
                details.push(candidate.reason);
                details.push(
                    '一致度' +
                    Math.round(
                        candidate.score * 100
                    ) +
                    '%'
                );
                item.appendChild(link);
                item.appendChild(
                    document.createTextNode(
                        ' — ' +
                        details.join('/')
                    )
                );
                list.appendChild(item);
            });
            panel.appendChild(heading);
            panel.appendChild(description);
            panel.appendChild(list);
            setStrongTone(
                strongCandidates.length > 0
            );
            if (strongCandidates.length > 0) {
                saveMessage.textContent =
                    '重複の可能性が高い候補が' +
                    strongCandidates.length +
                    '件あります。';
                saveWarning.hidden = false;
            } else {
                saveWarning.hidden = true;
            }
            console.log(
                'R16_ENTITY_DUPLICATE_RESULT',
                {
                    entity: config.template,
                    input: input.value,
                    target: target,
                    candidates: ranked,
                    strongCandidateCount:
                        strongCandidates.length
                }
            );
        }
        function scheduleRender() {
            if (composing) {
                return;
            }
            window.clearTimeout(timer);
            timer = window.setTimeout(
                render,
                250
            );
        }
        function shouldBlockSave() {
            return (
                normalizeSearch(input.value).length >= 2 &&
                !failed &&
                (
                    !ready ||
                    (
                        strongCandidates.length > 0 &&
                        !confirmation.checked
                    )
                )
            );
        }
        function blockSave(event) {
            if (!shouldBlockSave()) {
                return;
            }
            event.preventDefault();
            event.stopImmediatePropagation();
            if (!ready) {
                saveMessage.textContent =
                    '既存候補の確認が完了するまで' +
                    'お待ちください。';
            } else {
                saveMessage.textContent =
                    '既存候補を確認し、別データとして' +
                    '登録する場合はチェックしてください。';
            }
            saveWarning.hidden = false;
            saveWarning.scrollIntoView({
                behavior: 'smooth',
                block: 'center'
            });
            if (ready) {
                confirmation.focus();
            }
        }
        input.addEventListener(
            'compositionstart',
            function () {
                composing = true;
            }
        );
        input.addEventListener(
            'compositionend',
            function () {
                composing = false;
                scheduleRender();
            }
        );
        input.addEventListener(
            'input',
            scheduleRender
        );
        if (saveButton) {
            saveButton.addEventListener(
                'click',
                blockSave,
                true
            );
        }
        var formElement =
            input.closest('form');
        if (formElement) {
            formElement.addEventListener(
                'submit',
                function (event) {
                    var submitter =
                        event.submitter;
                    if (
                        submitter &&
                        submitter.name !== 'wpSave'
                    ) {
                        return;
                    }
                    blockSave(event);
                },
                true
            );
        }
        showSimple(
            '登録済み候補を読み込んでいます…'
        );
        api.get({
            action: 'cargoquery',
            format: 'json',
            tables: config.table,
            fields: config.fields,
            limit: 500
        }).then(function (data) {
            rows = (
                data.cargoquery || []
            ).map(function (item) {
                var value = item.title || {};
                return {
                    pageName:
                        value.page_name || '',
                    name:
                        value.name || '',
                    kana:
                        value.kana || '',
                    detail:
                        value.detail || ''
                };
            }).filter(function (row) {
                return (
                    row.pageName &&
                    row.name
                );
            });
            ready = true;
            failed = false;
            render();
            console.log(
                'R16_ENTITY_DUPLICATE_READY',
                {
                    entity: config.template,
                    loadedRows: rows.length
                }
            );
        }).catch(function (error) {
            ready = false;
            failed = true;
            render();
            console.error(
                '重複候補の取得に失敗しました。',
                error
            );
        });
        return true;
    }
    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            setupDuplicateChecker
        );
    } else {
        setupDuplicateChecker();
    }
    mw.hook('pf.formSetupAfter').add(
        setupDuplicateChecker
    );
    mw.hook('wikipage.content').add(
        setupDuplicateChecker
    );
});
/* === R16 Entity duplicate candidate checker END === */
/* === R16 Venue/Festival new-form name autofill START === */
/*
* PageFormsで指定した新規ページ名を、
* 空の会場名・祭り名へ初期値として反映する。
*
* 既に値がある場合は上書きしない。
* 祭りページ名に全角の区切り「|」がある場合は、
* 区切りより前だけを正式名称候補として使用する。
*/
(function () {
    'use strict';
    var installAttribute =
        'data-r16-name-prefill';
    var configs = [
        {
            entity: 'Venue',
            selector: '[name="Venue[name]"]',
            transform: function (targetName) {
                return targetName;
            }
        },
        {
            entity: 'Festival',
            selector: '[name="Festival[name]"]',
            transform: function (targetName) {
                return targetName
                    .split('|')[0]
                    .trim();
            }
        }
    ];
    function dispatchValueEvents(input) {
        input.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );
        input.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }
    function setupNamePrefill() {
        var canonicalSpecial =
            mw.config.get(
                'wgCanonicalSpecialPageName'
            );
        var targetName =
            String(
                mw.config.get(
                    'wgPageFormsTargetName'
                ) || ''
            ).trim();
        var form =
            document.getElementById('pfForm');
        var i;
        var config;
        var input;
        var value;
        if (
            canonicalSpecial !== 'FormEdit' ||
            targetName === '' ||
            targetName === 'Dummy title' ||
            !form
        ) {
            return false;
        }
        if (
            form.getAttribute(
                installAttribute
            ) === '1'
        ) {
            return true;
        }
        for (i = 0; i < configs.length; i++) {
            config = configs[i];
            input = form.querySelector(
                config.selector
            );
            if (!input) {
                continue;
            }
            form.setAttribute(
                installAttribute,
                '1'
            );
            if (
                String(input.value || '')
                    .trim() !== ''
            ) {
                return true;
            }
            value =
                config.transform(targetName);
            if (value === '') {
                return true;
            }
            input.value = value;
            dispatchValueEvents(input);
            console.info(
                'R16_NAME_PREFILL',
                {
                    entity: config.entity,
                    targetName: targetName,
                    value: value
                }
            );
            return true;
        }
        return false;
    }
    function installNamePrefill() {
        if (setupNamePrefill()) {
            return;
        }
        window.setTimeout(
            setupNamePrefill,
            0
        );
    }
    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            installNamePrefill,
            {
                once: true
            }
        );
    } else {
        installNamePrefill();
    }
    if (mw.hook) {
        mw.hook(
            'pf.formSetupAfter'
        ).add(
            installNamePrefill
        );
        mw.hook(
            'wikipage.content'
        ).add(
            installNamePrefill
        );
    }
}());
/* === R16 Venue/Festival new-form name autofill END === */

2026年9月12日 (土) 22:09時点における最新版

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

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

    'use strict';

    const STORAGE_KEY =
        'matsuriWikiComparePlacements';

    const MAX_COMPARE = 4;

    const api =
        new mw.Api();

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


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

    function getComparePlacements() {

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

        if ( !raw ) {
            return [];
        }

        try {

            const ids =
                JSON.parse( raw );

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

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

        } catch ( e ) {

            return [];

        }

    }


    function saveComparePlacements( ids ) {

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

    }


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

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

        const params = {

            action: 'cargoquery',

            tables: table,

            fields: fields,

            limit: limit || 100,

            format: 'json'

        };

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

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

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

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

            }
        );

    }


    function makeInClause( ids ) {

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

    }


    function uniqueIds( values ) {

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

    }


    function mapBy( rows, key ) {

        const result = {};

        rows.forEach(
            function ( row ) {

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

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

            }
        );

        return result;

    }


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

    function fetchPlacementInfo( ids ) {

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

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

        ids.forEach(
            function ( id ) {

                if (
                    placementInfoCache[ id ]
                ) {

                    result[ id ] =
                        placementInfoCache[ id ];

                } else {

                    missingIds.push( id );

                }

            }
        );


        if ( missingIds.length === 0 ) {

            return Promise.resolve(
                result
            );

        }


        return cargoQuery(

            'FestivalStallPlacements',

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

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

            100

        ).then(
            function ( placements ) {

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

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

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


                return Promise.all( [

                    stallIds.length
                        ? cargoQuery(

                            'Stalls',

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

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

                            100

                        )
                        : Promise.resolve( [] ),


                    festivalIds.length
                        ? cargoQuery(

                            'Festivals',

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

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

                            100

                        )
                        : Promise.resolve( [] ),


                    venueIds.length
                        ? cargoQuery(

                            'Venues',

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

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

                            100

                        )
                        : Promise.resolve( [] )

                ] ).then(
                    function ( related ) {

                        return {

                            placements:
                                placements,

                            stalls:
                                related[ 0 ],

                            festivals:
                                related[ 1 ],

                            venues:
                                related[ 2 ]

                        };

                    }
                );

            }
        ).then(
            function ( data ) {

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

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

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


                data.placements.forEach(
                    function ( placement ) {

                        const placementId =
                            String(
                                placement.placement_id
                            );

                        const info = {

                            placement:
                                placement,

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

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

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

                        };


                        placementInfoCache[
                            placementId
                        ] = info;

                        result[
                            placementId
                        ] = info;

                    }
                );


                return result;

            }
        );

    }


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

    function showMessage(
        control,
        message,
        isError
    ) {

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

        if ( !element ) {
            return;
        }

        element.textContent =
            message;

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

    }


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

function createCompareButtons() {

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

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


                let placementId = '';


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


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


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

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

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

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

                } else {

                    return;

                }


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


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


                button.type =
                    'button';


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


                button.dataset.placementId =
                    placementId;


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


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


                placeholder.appendChild(
                    button
                );


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

            }
        );

}

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

    function updateCompareButtons() {

        const ids =
            getComparePlacements();


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

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


                    const selected =
                        ids.includes(
                            placementId
                        );


                    if ( selected ) {

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

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

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

                    } else {

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

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

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

                    }

                }
            );

    }


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

    function createCompareTray() {

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


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

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

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

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


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

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


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

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

        title.textContent =
            '比較候補';


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

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


        header.appendChild(
            title
        );

        header.appendChild(
            count
        );


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

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


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

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


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

        clearButton.type =
            'button';

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

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


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

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

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

        compareLink.textContent =
            '比較する';


        actions.appendChild(
            clearButton
        );

        actions.appendChild(
            compareLink
        );


        tray.appendChild(
            header
        );

        tray.appendChild(
            items
        );

        tray.appendChild(
            actions
        );


        document.body.appendChild(
            tray
        );

    }


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

    function updateCompareTray() {

        createCompareTray();


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

        if ( !tray ) {
            return;
        }


        const ids =
            getComparePlacements();


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

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

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


        if ( count ) {

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

        }


        if ( ids.length === 0 ) {

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

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

            return;

        }


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


        if ( compareLink ) {

            if ( ids.length >= 2 ) {

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

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

            } else {

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

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

            }

        }


        if ( !items ) {
            return;
        }


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


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

                const currentIds =
                    getComparePlacements();

                items.innerHTML =
                    '';


                currentIds.forEach(
                    function ( placementId ) {

                        const info =
                            placementInfo[
                                placementId
                            ];


                        if ( !info ) {
                            return;
                        }


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

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


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


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


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

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

                        }


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

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


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

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


                        const parts = [];


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

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

                        }


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

                            parts.push(
                                info.festival
                                    .festival_name
                            );

                        }


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

                            parts.push(
                                info.venue
                                    .venue_name
                            );

                        }


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


                        text.appendChild(
                            name
                        );

                        text.appendChild(
                            context
                        );


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

                        removeButton.type =
                            'button';

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

                        removeButton.dataset.placementId =
                            placementId;

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

                        removeButton.textContent =
                            '×';


                        item.appendChild(
                            text
                        );

                        item.appendChild(
                            removeButton
                        );

                        items.appendChild(
                            item
                        );

                    }
                );

            }
        ).catch(
            function ( error ) {

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

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

            }
        );

    }


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

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

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

            if ( !button ) {
                return;
            }


            event.preventDefault();


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


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


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


            let ids =
                getComparePlacements();


            const index =
                ids.indexOf(
                    placementId
                );


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

                ids.splice(
                    index,
                    1
                );

                saveComparePlacements(
                    ids
                );

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


                if ( control ) {

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

                }

                return;

            }


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

                if ( control ) {

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

                }

                return;

            }


            ids.push(
                placementId
            );


            saveComparePlacements(
                ids
            );


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


            if ( control ) {

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

            }

        }
    );


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

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

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

            if ( !button ) {
                return;
            }


            event.preventDefault();


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


            let ids =
                getComparePlacements();


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


            saveComparePlacements(
                ids
            );


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

        }
    );


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

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

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

            if ( !button ) {
                return;
            }


            event.preventDefault();


            saveComparePlacements(
                []
            );


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

        }
    );


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

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

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

            if ( !link ) {
                return;
            }

            event.preventDefault();

        }
    );


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

function initPlacementCompare() {

    createCompareButtons();

    createCompareTray();

    updateCompareButtons();

    updateMapCompareLinks();

    updateCompareTray();

}


initPlacementCompare();


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

        initPlacementCompare();

    }
);


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

function getMapComparePlacementId( link ) {

    if ( !link ) {
        return '';
    }

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

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

    if ( !match ) {
        return '';
    }

    return match[ 1 ];
}


function updateMapCompareLinks() {

    const ids =
        getComparePlacements();

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

                const placementId =
                    getMapComparePlacementId(
                        link
                    );

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

                const selected =
                    ids.includes(
                        placementId
                    );

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

                link.dataset.placementId =
                    placementId;

                if ( selected ) {

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

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

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

                } else {

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

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

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

                }

            }
        );

}


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

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

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

        if ( !link ) {
            return;
        }

        const placementId =
            getMapComparePlacementId(
                link
            );

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

        event.preventDefault();

        let ids =
            getComparePlacements();

        const index =
            ids.indexOf(
                placementId
            );

        if ( index !== -1 ) {

            ids.splice(
                index,
                1
            );

        } else {

            if (
                ids.length >=
                MAX_COMPARE
            ) {

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

                return;
            }

            ids.push(
                placementId
            );

        }


        saveComparePlacements(
            ids
        );

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

    }
);


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

const mapCompareLinkObserver =
    new MutationObserver(
        function ( mutations ) {

            let needsUpdate =
                false;

            mutations.forEach(
                function ( mutation ) {

                    mutation.addedNodes.forEach(
                        function ( node ) {

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

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

                                needsUpdate =
                                    true;

                                return;
                            }

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

                                needsUpdate =
                                    true;

                            }

                        }
                    );

                }
            );


            if ( needsUpdate ) {

                updateMapCompareLinks();

            }

        }
    );


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


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

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

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

    'use strict';


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


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


    const api =
        new mw.Api();


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

    function normalizeSearchText(
        value
    ) {

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


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


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

            text =
                text.normalize(
                    'NFKC'
                );

        }


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


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


        return text;

    }


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

    function cargoQuery(
        table,
        fields,
        where
    ) {

        const params = {

            action:
                'cargoquery',

            tables:
                table,

            fields:
                fields,

            limit:
                500,

            format:
                'json'

        };


        if (
            where
        ) {

            params.where =
                where;

        }


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

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


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

                        return (
                            item.title ||
                            item
                        );

                    }
                );

            }
        );

    }


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

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

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

                }
            )
            .filter(
                function ( id ) {

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

                }
            );


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

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

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

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

                }
            )
            .map(
                function ( card ) {

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

                }
            )
            .filter(
                function ( id ) {

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

                }
            );


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

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

const festivalMapMarkerIndex =
    {};


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

/*
 * R10-5C ISSUE-07:
 * Festival地図の初期viewportを
 * marker群へ合わせたか。
 */
let festivalMapInitialViewportApplied =
    false;

/*
 * R10-5C7:
 * Maps拡張の初期center/zoom処理が
 * 完了した次taskでviewportを適用する。
 */
let festivalMapInitialViewportTimer =
    null;

let festivalMapInitialViewportAttempts =
    0;

const MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS =
    40;


/*
 * 地図生成前に検索された場合に備えて
 * 最新の表示対象を保持
 */
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
    ) {
        scheduleFestivalMapInitialViewport();

        return true;
    }


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

        festivalMapMarkerIndexReady =
            true;

        return true;
    }


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


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


    window.mapsLeafletList.forEach(
        function ( jqueryMap ) {

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


            const markerLayer =
                jqueryMap
                    .mapContent
                    .markerLayer;


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


            const markers =
                markerLayer.getLayers();


            markers.forEach(
                function ( marker ) {

                    const placementId =
                        getPlacementIdFromMapMarker(
                            marker
                        );


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


festivalMapMarkerIndex[
    placementId
] = {

    marker:
        marker,

    markerLayer:
        markerLayer,

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

};

                }
            );

        }
    );


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

                return Boolean(
                    festivalMapMarkerIndex[
                        placementId
                    ]
                );

            }
        );


    if (
        festivalMapMarkerIndexReady
    ) {
        scheduleFestivalMapInitialViewport();
    }


    return (
        festivalMapMarkerIndexReady
    );

}


/*
 * =====================================
 * R10-5C ISSUE-07
 * Festival「地図から探す」初期viewport
 *
 * 0 marker:
 *   現行fallbackを維持
 *
 * 1 marker:
 *   marker中央、zoom上限17
 *
 * 2 marker以上:
 *   全markerをfitBounds
 *   padding 32px
 *   maxZoom 17
 * =====================================
 */
function scheduleFestivalMapInitialViewport() {

    if (
        festivalMapInitialViewportApplied ||
        !festivalMapMarkerIndexReady
    ) {
        return false;
    }


    /*
     * 二重timer防止。
     */
    if (
        festivalMapInitialViewportTimer !==
            null
    ) {
        return true;
    }


    /*
     * Festival地図に属するmarkerから
     * 対象Leaflet mapを特定する。
     */
    const indexedItem =
        mapPlacementIds
            .map(
                String
            )
            .map(
                function (
                    placementId
                ) {
                    return (
                        festivalMapMarkerIndex[
                            placementId
                        ] ||
                        null
                    );
                }
            )
            .find(
                function ( item ) {
                    return Boolean(
                        item &&
                        item.marker &&
                        item.markerLayer &&
                        item.markerLayer._map
                    );
                }
            );


    /*
     * 座標付きplacement自体が0件なら
     * Maps側のfallbackを正式採用して完了。
     *
     * placementが存在するのにindexedItemが
     * まだ取れない場合は初期化途中なので、
     * applied=trueにせず再試行する。
     */
    if (
        !indexedItem
    ) {

        if (
            mapPlacementIds.length ===
                0
        ) {
            festivalMapInitialViewportApplied =
                true;

            return true;
        }


        festivalMapInitialViewportAttempts +=
            1;


        if (
            festivalMapInitialViewportAttempts >=
            MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
        ) {
            console.warn(
                '祭り屋台地図:markerのLeaflet map接続を確認できなかったため、初期viewport調整を中止しました。'
            );

            return false;
        }


        festivalMapInitialViewportTimer =
            window.setTimeout(
                function () {

                    festivalMapInitialViewportTimer =
                        null;

                    scheduleFestivalMapInitialViewport();
                },
                100
            );


        return true;
    }


    const targetMap =
        indexedItem
            .markerLayer
            ._map;


    const mapsEntry =
        Array.isArray(
            window.mapsLeafletList
        )
            ? window.mapsLeafletList
                .find(
                    function ( entry ) {
                        return Boolean(
                            entry &&
                            entry.map ===
                                targetMap
                        );
                    }
                )
            : null;


    /*
     * Maps setup() は doSetup() 冒頭で
     * ranSetup=true にした後、
     * centerAndZoomMap() を実行する。
     *
     * ranSetup=trueでも同一call stack中なら
     * Maps側のzoom=18がまだ後続するため、
     * 必ず次taskへ送る。
     *
     * ranSetup前なら100ms単位で待機する。
     */
    const delay =
        mapsEntry &&
        mapsEntry.ranSetup ===
            true
            ? 0
            : 100;


    festivalMapInitialViewportTimer =
        window.setTimeout(
            function () {

                festivalMapInitialViewportTimer =
                    null;


                /*
                 * timer実行時点でもMaps setupが
                 * 完了していなければ再試行。
                 */
                if (
                    !mapsEntry ||
                    mapsEntry.ranSetup !==
                        true
                ) {

                    festivalMapInitialViewportAttempts +=
                        1;


                    if (
                        festivalMapInitialViewportAttempts >=
                        MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
                    ) {
                        console.warn(
                            '祭り屋台地図:Maps初期化完了を確認できなかったため、初期viewport調整を中止しました。'
                        );

                        return;
                    }


                    scheduleFestivalMapInitialViewport();

                    return;
                }


                if (
                    applyFestivalMapInitialViewport()
                ) {
                    festivalMapInitialViewportAttempts =
                        0;

                    return;
                }


                festivalMapInitialViewportAttempts +=
                    1;


                if (
                    festivalMapInitialViewportAttempts >=
                    MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
                ) {
                    console.warn(
                        '祭り屋台地図:初期viewportを適用できなかったため、再試行を中止しました。'
                    );

                    return;
                }


                scheduleFestivalMapInitialViewport();
            },
            delay
        );


    return true;
}


function applyFestivalMapInitialViewport() {

    if (
        festivalMapInitialViewportApplied ||
        !festivalMapMarkerIndexReady
    ) {
        return false;
    }


    if (
        typeof L === 'undefined'
    ) {
        return false;
    }


    const items =
        mapPlacementIds
            .map(
                String
            )
            .map(
                function (
                    placementId
                ) {
                    return (
                        festivalMapMarkerIndex[
                            placementId
                        ] ||
                        null
                    );
                }
            )
            .filter(
                function ( item ) {
                    return Boolean(
                        item &&
                        item.marker &&
                        typeof item.marker
                            .getLatLng ===
                            'function' &&
                        item.markerLayer
                    );
                }
            );


    /*
     * mapPlacementIdsが存在する状態で
     * itemsが0件なのは初期化途中。
     *
     * applied=trueにはせず、
     * schedulerへfalseを返して再試行させる。
     */
    if (
        items.length === 0
    ) {
        return false;
    }


    const map =
        items[
            0
        ].markerLayer &&
        items[
            0
        ].markerLayer._map
            ? items[
                0
            ].markerLayer._map
            : null;


    if (
        !map ||
        typeof map.setView !==
            'function' ||
        typeof map.fitBounds !==
            'function'
    ) {
        return false;
    }


    /*
     * 同一Festival地図に属するmarkerだけを
     * viewport計算へ使用。
     */
    const latLngs =
        items
            .filter(
                function ( item ) {
                    return (
                        item.markerLayer &&
                        item.markerLayer._map ===
                            map
                    );
                }
            )
            .map(
                function ( item ) {
                    return item.marker
                        .getLatLng();
                }
            )
            .filter(
                function ( latlng ) {
                    return Boolean(
                        latlng &&
                        Number.isFinite(
                            Number(
                                latlng.lat
                            )
                        ) &&
                        Number.isFinite(
                            Number(
                                latlng.lng
                            )
                        )
                    );
                }
            );


    if (
        latLngs.length === 0
    ) {
        return false;
    }


    if (
        latLngs.length === 1
    ) {

        map.setView(
            latLngs[
                0
            ],
            17,
            {
                animate:
                    false
            }
        );

    } else {

        map.fitBounds(
            L.latLngBounds(
                latLngs
            ),
            {
                padding:
                    [
                        32,
                        32
                    ],

                maxZoom:
                    17,

                animate:
                    false
            }
        );

    }


    festivalMapInitialViewportApplied =
        true;

    return true;
}


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

function applyMapMarkerFilter(
    visiblePlacementIds
) {

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


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

            const item =
                festivalMapMarkerIndex[
                    placementId
                ];


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


            const marker =
                item.marker;

            const markerLayer =
                item.markerLayer;


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


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

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

                    markerLayer.addLayer(
                        marker
                    );

                }


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

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

                    markerLayer.removeLayer(
                        marker
                    );

                }

            }

        }
    );

}


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

function scheduleMapMarkerIndex() {

    if (
        festivalMapMarkerIndexReady
    ) {

        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );

        return;
    }


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


    function tryIndex() {

        mapIndexTimer =
            null;


        if (
            buildFestivalMapMarkerIndex()
        ) {

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

            return;
        }


        mapIndexAttempts +=
            1;


        if (
            mapIndexAttempts >=
            MAX_MAP_INDEX_ATTEMPTS
        ) {

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

            return;
        }


        mapIndexTimer =
            window.setTimeout(
                tryIndex,
                100
            );

    }


    tryIndex();

}


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

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


    if (
        buildFestivalMapMarkerIndex()
    ) {

        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );

        return;
    }


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

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

function openPlacementOnMap(
    placementId
) {

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


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


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

        buildFestivalMapMarkerIndex();

    }


    const item =
        festivalMapMarkerIndex[
            id
        ];


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

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

        return false;

    }


    const marker =
        item.marker;


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

        item.markerLayer.addLayer(
            marker
        );

    }


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

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

                block:
                    'center'
            }
        );

    }


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

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

                marker.openPopup();

            }

        },
        300
    );


    return true;

}

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

function createMapViewButtons() {

    cards.forEach(
        function ( card ) {

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


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


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


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


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

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


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

            button.type =
                'button';

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

            button.dataset.placementId =
                placementId;

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


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


            wrapper.appendChild(
                button
            );


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


            if (
                compareControl &&
                compareControl.parentNode
            ) {

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

            } else {

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

            }

        }
    );

}

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

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

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


        if (
            !button
        ) {
            return;
        }


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


        const opened =
            openPlacementOnMap(
                placementId
            );


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

            scheduleMapMarkerIndex();


            button.disabled =
                true;

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


            window.setTimeout(
                function () {

                    button.disabled =
                        false;

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


                    openPlacementOnMap(
                        placementId
                    );

                },
                500
            );

        }

    }
);


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

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

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


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

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

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


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

    input.type =
        'search';

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

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

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

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

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

function createFilterSelect(
    labelText,
    className,
    allText
) {

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

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


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

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

    title.textContent =
        labelText;


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

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


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

    allOption.value =
        '';

    allOption.textContent =
        allText;


    select.appendChild(
        allOption
    );


    wrapper.appendChild(
        title
    );

    wrapper.appendChild(
        select
    );


    return {
        wrapper:
            wrapper,

        select:
            select
    };

}


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


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


const categorySelect =
    categoryFilter.select;


const venueSelect =
    venueFilter.select;


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

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


filterRow.appendChild(
    categoryFilter.wrapper
);

filterRow.appendChild(
    venueFilter.wrapper
);

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

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

resetButton.type =
    'button';

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

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

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

resetButton.disabled =
    true;

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

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

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

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

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

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

noResults.hidden =
    true;

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

    label.appendChild(
        input
    );

searchBox.appendChild(
    label
);

searchBox.appendChild(
    filterRow
);


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


searchBox.appendChild(
    count
);

searchBox.appendChild(
    noResults
);


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


if (
    searchAnchor
) {

    searchAnchor.appendChild(
        searchBox
    );

} else {

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

}

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

const searchIndex = {};


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

const venueOptions =
    new Map();


cards.forEach(
    function ( card ) {

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


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


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


        const normalizedCategory =
            normalizeSearchText(
                category
            );


        const normalizedVenue =
            normalizeSearchText(
                venueName
            );


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

            text:
                normalizeSearchText(
                    card.textContent
                ),

            category:
                normalizedCategory,

            venueName:
                normalizedVenue

        };


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

            categoryOptions.set(
                normalizedCategory,
                category
            );

        }


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

            venueOptions.set(
                normalizedVenue,
                venueName
            );

        }

    }
);

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

function fillFilterOptions(
    select,
    optionMap
) {

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


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

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

        }
    );


    options.forEach(
        function ( optionData ) {

            const value =
                optionData[
                    0
                ];

            const label =
                optionData[
                    1
                ];


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

            option.value =
                value;

            option.textContent =
                label;


            select.appendChild(
                option
            );

        }
    );

}


fillFilterOptions(
    categorySelect,
    categoryOptions
);


fillFilterOptions(
    venueSelect,
    venueOptions
);

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

    function updateCount(
        visible
    ) {

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

    }


    updateCount(
        cards.length
    );


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

function applySearch() {

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


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


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


let visible =
    0;


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


cards.forEach(
        function ( card ) {

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


            const index =
                searchIndex[
                    placementId
                ] || {

                    text:
                        '',

                    category:
                        '',

                    venueName:
                        ''

                };


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

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


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

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


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

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


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

            const matched =
                keywordMatched &&
                categoryMatched &&
                venueMatched;


if (
    matched
) {

    card.style.display =
        '';

    visible +=
        1;


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

} else {

                card.style.display =
                    'none';

            }

        }
    );


updateCount(
    visible
);


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


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


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


}


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

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


    input.addEventListener(
        'input',
        applySearch
    );

categorySelect.addEventListener(
    'change',
    applySearch
);


venueSelect.addEventListener(
    'change',
    applySearch
);

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

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

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


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


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


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


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

    }
);

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

    cargoQuery(

        'FestivalStallMenuOfferings',

        'placement_id=placement_id,' +
        'menu_item_id=menu_item_id',

        'placement_id IN (' +
        placementIds.join(
            ','
        ) +
        ')'

    ).then(
        function ( offerings ) {


            const menuItemIds =
                [
                    ...new Set(
                        offerings
                            .map(
                                function (
                                    offering
                                ) {

                                    return String(
                                        offering
                                            .menu_item_id ||
                                        ''
                                    );

                                }
                            )
                            .filter(
                                function ( id ) {

                                    return /^\d+$/.test(
                                        id
                                    );

                                }
                            )
                    )
                ];


            /*
             * メニューが1件も無い
             */
            if (
                menuItemIds.length === 0
            ) {

                return {
                    offerings:
                        offerings,

                    menus:
                        []
                };

            }


            /* =================================
             * MenuItem名取得
             * ================================= */

            return cargoQuery(

                'StallMenuItems',

                'menu_item_id=menu_item_id,' +
                'name=menu_name',

                'menu_item_id IN (' +
                menuItemIds.join(
                    ','
                ) +
                ')'

            ).then(
                function ( menus ) {

                    return {

                        offerings:
                            offerings,

                        menus:
                            menus

                    };

                }
            );

        }
    ).then(
        function ( data ) {

            if (
                !data
            ) {
                return;
            }


            /* =================================
             * menu_item_id → 商品名
             * ================================= */

            const menuNameMap =
                {};


            data.menus.forEach(
                function ( menu ) {

                    menuNameMap[
                        String(
                            menu.menu_item_id
                        )
                    ] =
                        menu.menu_name ||
                        '';

                }
            );


            /* =================================
             * placement_id → 商品名[]
             * ================================= */

            const placementMenus =
                {};


            data.offerings.forEach(
                function ( offering ) {

                    const placementId =
                        String(
                            offering
                                .placement_id ||
                            ''
                        );

                    const menuItemId =
                        String(
                            offering
                                .menu_item_id ||
                            ''
                        );


                    const menuName =
                        menuNameMap[
                            menuItemId
                        ] || '';


                    if (
                        !menuName
                    ) {
                        return;
                    }


                    if (
                        !placementMenus[
                            placementId
                        ]
                    ) {

                        placementMenus[
                            placementId
                        ] = [];

                    }


                    placementMenus[
                        placementId
                    ].push(
                        menuName
                    );

                }
            );


            /* =================================
             * 商品名を検索インデックスへ追加
             * ================================= */

            cards.forEach(
                function ( card ) {

                    const placementId =
                        String(
                            card.dataset
                                .placementId ||
                            ''
                        );


                    const menuNames =
                        placementMenus[
                            placementId
                        ] || [];


if (
    searchIndex[
        placementId
    ]
) {

    searchIndex[
        placementId
    ].text =
        normalizeSearchText(
            (
                searchIndex[
                    placementId
                ].text ||
                ''
            ) +
            ' ' +
            menuNames.join(
                ' '
            )
        );

}

                }
            );


            /*
             * 商品データ取得後、
             * 入力済み検索を再判定
             */
            applySearch();

        }
    ).catch(
        function ( error ) {

            /*
             * 商品データ取得に失敗しても
             * 屋台名検索は使えるようにする
             */
            console.error(
                '屋台商品検索データ取得エラー:',
                error
            );

        }
    );

} );

/* ========================================
 * 屋台比較ページ
 * placement_id 正式版
 * ======================================== */

mw.loader.using( [
    'mediawiki.storage',
    'mediawiki.api',
    'mediawiki.util'
] ).then( function () {

    'use strict';


    const compareRoot =
        document.getElementById(
            'stall-compare-page'
        );


    /*
     * 屋台比較ページ以外では終了
     */
    if ( !compareRoot ) {
        return;
    }


    const STORAGE_KEY =
        'matsuriWikiComparePlacements';

    const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;

    const api =
        new mw.Api();


    /* =====================================
     * localStorage
     * ===================================== */

    function getPlacementIds() {

        const raw =
            mw.storage.get(
                STORAGE_KEY
            );


        if ( !raw ) {
            return [];
        }


        try {

            const ids =
                JSON.parse(
                    raw
                );


            if (
                !Array.isArray(
                    ids
                )
            ) {
                return [];
            }


            return [ ...new Set(
                ids
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test(
                                id
                            );
                        }
                    )
            ) ].slice(
                0,
                MAX_COMPARE
            );


        } catch ( e ) {

            return [];

        }

    }


    /* =====================================
     * Cargo
     * ===================================== */

    function cargoQuery(
        table,
        fields,
        where,
        limit
    ) {

        const params = {

            action: 'cargoquery',

            tables: table,

            fields: fields,

            limit: limit || 100,

            format: 'json'

        };


        if ( where ) {
            params.where = where;
        }


        return api.get(
            params
        ).then(
            function ( data ) {

                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }


                return data.cargoquery.map(
                    function ( item ) {

                        return (
                            item.title ||
                            item
                        );

                    }
                );

            }
        );

    }


    function makeInClause( ids ) {

        return ids
            .map( String )
            .filter(
                function ( id ) {
                    return /^\d+$/.test(
                        id
                    );
                }
            )
            .join( ',' );

    }


    function uniqueIds( values ) {

        return [
            ...new Set(
                values
                    .map( String )
                    .filter(
                        function ( id ) {

                            return (
                                id &&
                                /^\d+$/.test(
                                    id
                                )
                            );

                        }
                    )
            )
        ];

    }


    function mapBy(
        rows,
        key
    ) {

        const result = {};


        rows.forEach(
            function ( row ) {

                if (
                    row[ key ] ===
                    undefined
                ) {
                    return;
                }


                result[
                    String(
                        row[ key ]
                    )
                ] = row;

            }
        );


        return result;

    }


    /* =====================================
     * 表示ヘルパー
     * ===================================== */

    function textOrDash(
        value
    ) {

        if (
            value === undefined ||
            value === null ||
            value === ''
        ) {
            return '―';
        }

        return String(
            value
        );

    }


function cleanNumber(
    value
) {

    if (
        value === undefined ||
        value === null ||
        String( value ).trim() === ''
    ) {
        return '';
    }

    const number =
        Number(
            value
        );

    if (
        !Number.isFinite(
            number
        )
    ) {
        return '';
    }

    if (
        Number.isInteger(
            number
        )
    ) {

        return String(
            number
        );

    }

    return String(
        Math.round(
            number * 100
        ) / 100
    );

}


/* =====================================
 * 比較計算用数値
 * ===================================== */

function toFiniteNumber(
    value
) {

    if (
        value === undefined ||
        value === null ||
        String( value ).trim() === ''
    ) {
        return null;
    }

    const number =
        Number(
            value
        );

    if (
        !Number.isFinite(
            number
        )
    ) {
        return null;
    }

    return number;

}

    function formatHours(
        placement
    ) {

        if ( !placement ) {
            return '―';
        }


        const open =
            placement.opening_time || '';

        const close =
            placement.closing_time || '';


        if (
            open &&
            close
        ) {

            return (
                open +
                '~' +
                close
            );

        }


        if ( open ) {

            return (
                open +
                '~'
            );

        }


        if ( close ) {

            return (
                '~' +
                close
            );

        }


        if (
            placement.hours_note
        ) {

            return placement.hours_note;

        }


        return '未確認';

    }


    function formatPositionStatus(
        status
    ) {

        switch ( status ) {

case 'exact':
    return '正確な位置';

case 'approximate':
    return 'おおよその位置';

default:
    return '位置未確認';

        }

    }


    function formatVerification(
        status
    ) {

        switch ( status ) {

            case 'verified':
                return '確認済み';

            case 'partially_verified':
                return '一部確認済み';

            case 'outdated':
                return '情報が古い';

            default:
                return '未確認';

        }

    }


    function formatAvailability(
        status
    ) {

        switch ( status ) {

            case 'available':
                return '販売あり';

            case 'unavailable':
                return '販売なし';

            default:
                return '未確認';

        }

    }


/* =====================================
 * 単位価格
 * 表示用
 * ===================================== */

function getUnitPrice(
    offering
) {

    const price =
        toFiniteNumber(
            offering.price
        );

    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


    if (
        price === null ||
        quantity === null ||
        quantity <= 0
    ) {

        return '―';

    }


    const unitPrice =
        Math.round(
            (
                price /
                quantity
            ) *
            100
        ) /
        100;


    const unit =
        offering.serving_unit ||
        '単位';


    return (
        unitPrice +
        '円/' +
        unit
    );

}


/* =====================================
 * 単位価格
 * 比較計算用
 * ===================================== */

function getUnitPriceValue(
    offering
) {

    const price =
        toFiniteNumber(
            offering.price
        );

    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


    if (
        price === null ||
        quantity === null ||
        quantity <= 0
    ) {

        return null;

    }


    return (
        price /
        quantity
    );

}


    /* =====================================
     * DOM
     * ===================================== */

    function createTextCell(
        tagName,
        text
    ) {

        const cell =
            document.createElement(
                tagName
            );

        cell.textContent =
            text;

        return cell;

    }


    /* =====================================
     * メニュー
     * ===================================== */

    function createMenuList(
        menus
    ) {

        const container =
            document.createElement(
                'div'
            );

        container.className =
            'stall-compare-menu-list';


        if (
            !menus ||
            menus.length === 0
        ) {

            container.textContent =
                'メニュー未登録';

            return container;

        }


        menus.forEach(
            function ( item ) {

                const menu =
                    document.createElement(
                        'div'
                    );

                menu.className =
                    'stall-compare-menu-item';


                const name =
                    document.createElement(
                        'strong'
                    );

                name.className =
                    'stall-compare-menu-name';

                name.textContent =
                    item.menuName ||
                    '商品';


const price =
    document.createElement(
        'div'
    );

price.className =
    'stall-compare-menu-price';


price.textContent =
    item.price
        ? item.price +
          '円'
        : '価格未確認';


/*
 * 最安価格
 */
if (
    item.isLowestPrice
) {

    const badge =
        document.createElement(
            'span'
        );

    badge.className =
        'stall-compare-best-badge ' +
        'stall-compare-best-price';

    badge.textContent =
        '最安価格';


    price.appendChild(
        document.createTextNode(
            ' '
        )
    );

    price.appendChild(
        badge
    );

}


                const serving =
                    document.createElement(
                        'div'
                    );


                if (
                    item.servingQuantity
                ) {

                    serving.textContent =
                        '内容量:' +
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );

                } else {

                    serving.textContent =
                        '内容量:未確認';

                }


const unit =
    document.createElement(
        'div'
    );

unit.className =
    'stall-compare-menu-unit-price';


unit.textContent =
    '1単位あたり:' +
    item.unitPrice;


/*
 * 最安単位価格
 */
if (
    item.isLowestUnitPrice
) {

    const badge =
        document.createElement(
            'span'
        );

    badge.className =
        'stall-compare-best-badge ' +
        'stall-compare-best-unit-price';

    badge.textContent =
        '最安単位価格';


    unit.appendChild(
        document.createTextNode(
            ' '
        )
    );

    unit.appendChild(
        badge
    );

}


                const availability =
                    document.createElement(
                        'div'
                    );

                availability.textContent =
                    '販売状況:' +
                    item.availability;


                menu.appendChild(
                    name
                );

                menu.appendChild(
                    price
                );

                menu.appendChild(
                    serving
                );

                menu.appendChild(
                    unit
                );

                menu.appendChild(
                    availability
                );


                container.appendChild(
                    menu
                );

            }
        );


        return container;

    }


    /* =====================================
     * 比較表
     * ===================================== */

    function renderComparison(
        compareData
    ) {

        compareRoot.innerHTML =
            '';


        const heading =
            document.createElement(
                'h2'
            );

        heading.textContent =
            '屋台比較';


        compareRoot.appendChild(
            heading
        );


        const wrapper =
            document.createElement(
                'div'
            );

        wrapper.className =
            'stall-compare-table-wrapper';


        const table =
            document.createElement(
                'table'
            );

        table.className =
            'stall-compare-table';


        /* ------------------------------
         * thead
         * ------------------------------ */

        const thead =
            document.createElement(
                'thead'
            );

        const headerRow =
            document.createElement(
                'tr'
            );


        headerRow.appendChild(
            createTextCell(
                'th',
                '比較項目'
            )
        );


        compareData.forEach(
            function ( data ) {

                const th =
                    document.createElement(
                        'th'
                    );


                if (
                    data.stall &&
                    data.stall.page_name
                ) {

                    const link =
                        document.createElement(
                            'a'
                        );

                    link.href =
                        mw.util.getUrl(
                            data.stall
                                .page_name
                        );

                    link.textContent =
                        data.stall
                            .stall_name ||
                        '屋台';


                    th.appendChild(
                        link
                    );

                } else {

                    th.textContent =
                        data.stall
                            ? data.stall.stall_name
                            : '屋台';

                }


                headerRow.appendChild(
                    th
                );

            }
        );


        thead.appendChild(
            headerRow
        );

        table.appendChild(
            thead
        );


        const tbody =
            document.createElement(
                'tbody'
            );


        function addRow(
            label,
            getter
        ) {

            const tr =
                document.createElement(
                    'tr'
                );


            const labelCell =
                createTextCell(
                    'th',
                    label
                );

            labelCell.scope =
                'row';


            tr.appendChild(
                labelCell
            );


            compareData.forEach(
                function ( data ) {

                    tr.appendChild(
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );

                }
            );


            tbody.appendChild(
                tr
            );

        }


        /* =================================
         * Placement情報
         * ================================= */

        addRow(
            '開催年',
            function ( data ) {

                return data.placement
                    ? data.placement.year +
                      '年'
                    : '―';

            }
        );


        addRow(
            '祭り',
            function ( data ) {

                return data.festival
                    ? data.festival
                        .festival_name
                    : '―';

            }
        );


        addRow(
            '会場',
            function ( data ) {

                return data.venue
                    ? data.venue
                        .venue_name
                    : '―';

            }
        );


        addRow(
            '地域',
            function ( data ) {

                return data.area
                    ? data.area
                        .area_name
                    : '―';

            }
        );


        addRow(
            'カテゴリ',
            function ( data ) {

                return data.stall
                    ? data.stall
                        .category
                    : '―';

            }
        );


        addRow(
            '出店場所',
            function ( data ) {

                return data.placement
                    ? data.placement
                        .location_note
                    : '―';

            }
        );


        addRow(
            '営業時間',
            function ( data ) {

                return formatHours(
                    data.placement
                );

            }
        );


        addRow(
            '位置情報',
            function ( data ) {

                return data.placement
                    ? formatPositionStatus(
                        data.placement
                            .position_status
                    )
                    : '―';

            }
        );


        addRow(
            '確認状態',
            function ( data ) {

                return data.placement
                    ? formatVerification(
                        data.placement
                            .verification_status
                    )
                    : '―';

            }
        );


        /* =================================
         * メニュー
         * ================================= */

        const menuRow =
            document.createElement(
                'tr'
            );


        const menuLabel =
            createTextCell(
                'th',
                'メニュー'
            );

        menuLabel.scope =
            'row';


        menuRow.appendChild(
            menuLabel
        );


        compareData.forEach(
            function ( data ) {

                const td =
                    document.createElement(
                        'td'
                    );


                td.appendChild(
                    createMenuList(
                        data.menus
                    )
                );


                menuRow.appendChild(
                    td
                );

            }
        );


        tbody.appendChild(
            menuRow
        );


        table.appendChild(
            tbody
        );

        wrapper.appendChild(
            table
        );

        compareRoot.appendChild(
            wrapper
        );


        const note =
            document.createElement(
                'p'
            );

        note.className =
            'stall-compare-note';

        note.textContent =
            'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';


        compareRoot.appendChild(
            note
        );

    }


    function renderMessage(
        message
    ) {

        compareRoot.innerHTML =
            '';


        const p =
            document.createElement(
                'p'
            );

        p.className =
            'stall-compare-page-message';

        p.textContent =
            message;


        compareRoot.appendChild(
            p
        );

    }


    /* =====================================
     * データ取得
     * ===================================== */

    const placementIds =
        getPlacementIds();


    if (
        placementIds.length <
        MIN_COMPARE
    ) {

        renderMessage(
            '比較する出店を2件以上選択してください。'
        );

        return;

    }


    /*
     * STEP 1
     * Placementを直接取得
     */
    cargoQuery(

        'FestivalStallPlacements',

        'placement_id=placement_id,' +
        'stall_id=stall_id,' +
        'festival_id=festival_id,' +
        'venue_id=venue_id,' +
        'year=year,' +
        'latitude=latitude,' +
        'longitude=longitude,' +
        'location_note=location_note,' +
        'opening_time=opening_time,' +
        'closing_time=closing_time,' +
        'hours_note=hours_note,' +
        'position_status=position_status,' +
        'verification_status=verification_status',

        'placement_id IN (' +
        makeInClause(
            placementIds
        ) +
        ')',

        100

    ).then(
        function ( placements ) {

            const stallIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.stall_id;
                        }
                    )
                );


            const festivalIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.festival_id;
                        }
                    )
                );


            const venueIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.venue_id;
                        }
                    )
                );


            /*
             * STEP 2
             */
            return Promise.all( [

                stallIds.length
                    ? cargoQuery(

                        'Stalls',

                        'stall_id=stall_id,' +
                        'name=stall_name,' +
                        'category=category,' +
                        '_pageName=page_name',

                        'stall_id IN (' +
                        makeInClause(
                            stallIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                festivalIds.length
                    ? cargoQuery(

                        'Festivals',

                        'festival_id=festival_id,' +
                        'name=festival_name,' +
                        '_pageName=page_name',

                        'festival_id IN (' +
                        makeInClause(
                            festivalIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                venueIds.length
                    ? cargoQuery(

                        'Venues',

                        'venue_id=venue_id,' +
                        'name=venue_name,' +
                        'area_id=area_id,' +
                        '_pageName=page_name',

                        'venue_id IN (' +
                        makeInClause(
                            venueIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                cargoQuery(

                    'FestivalStallMenuOfferings',

                    'placement_id=placement_id,' +
                    'menu_item_id=menu_item_id,' +
                    'price=price,' +
                    'serving_quantity=serving_quantity,' +
                    'serving_unit=serving_unit,' +
                    'availability=availability,' +
                    'verification_status=verification_status,' +
                    'sort_order=sort_order',

                    'placement_id IN (' +
                    makeInClause(
                        placementIds
                    ) +
                    ')',

                    100

                )

            ] ).then(
                function ( results ) {

                    return {

                        placements:
                            placements,

                        stalls:
                            results[ 0 ],

                        festivals:
                            results[ 1 ],

                        venues:
                            results[ 2 ],

                        offerings:
                            results[ 3 ]

                    };

                }
            );

        }
    ).then(
        function ( data ) {

            const areaIds =
                uniqueIds(
                    data.venues.map(
                        function ( row ) {
                            return row.area_id;
                        }
                    )
                );


            const menuItemIds =
                uniqueIds(
                    data.offerings.map(
                        function ( row ) {
                            return row.menu_item_id;
                        }
                    )
                );


            /*
             * STEP 3
             */
            return Promise.all( [

                areaIds.length
                    ? cargoQuery(

                        'Areas',

                        'area_id=area_id,' +
                        'name=area_name,' +
                        '_pageName=page_name',

                        'area_id IN (' +
                        makeInClause(
                            areaIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                menuItemIds.length
                    ? cargoQuery(

                        'StallMenuItems',

'menu_item_id=menu_item_id,' +
'stall_id=stall_id,' +
'name=menu_name,' +
'item_category=item_category',

                        'menu_item_id IN (' +
                        makeInClause(
                            menuItemIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    )

            ] ).then(
                function ( results ) {

                    data.areas =
                        results[ 0 ];

                    data.menuItems =
                        results[ 1 ];

                    return data;

                }
            );

        }
    ).then(
        function ( data ) {

            const placementMap =
                mapBy(
                    data.placements,
                    'placement_id'
                );


            const stallMap =
                mapBy(
                    data.stalls,
                    'stall_id'
                );


            const festivalMap =
                mapBy(
                    data.festivals,
                    'festival_id'
                );


            const venueMap =
                mapBy(
                    data.venues,
                    'venue_id'
                );


            const areaMap =
                mapBy(
                    data.areas,
                    'area_id'
                );


            const menuMap =
                mapBy(
                    data.menuItems,
                    'menu_item_id'
                );


            /*
             * Offering
             * placement単位
             */
            const offeringsByPlacement =
                {};


            data.offerings
                .slice()
                .sort(
                    function ( a, b ) {

                        return (
                            Number(
                                a.sort_order || 0
                            ) -
                            Number(
                                b.sort_order || 0
                            )
                        );

                    }
                )
                .forEach(
                    function ( offering ) {

                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );


                        if (
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {

                            offeringsByPlacement[
                                placementId
                            ] = [];

                        }


                        offeringsByPlacement[
                            placementId
                        ].push(
                            offering
                        );

                    }
                );


            /*
             * localStorage順を維持
             */
            const compareData =
                placementIds.map(
                    function (
                        placementId
                    ) {

                        const placement =
                            placementMap[
                                placementId
                            ] || null;


                        if ( !placement ) {

                            return {

                                placementId:
                                    placementId,

                                placement:
                                    null,

                                stall:
                                    null,

                                festival:
                                    null,

                                venue:
                                    null,

                                area:
                                    null,

                                menus:
                                    []

                            };

                        }


                        const stall =
                            stallMap[
                                String(
                                    placement.stall_id
                                )
                            ] || null;


                        const festival =
                            festivalMap[
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                        const venue =
                            venueMap[
                                String(
                                    placement.venue_id
                                )
                            ] || null;


                        let area = null;


                        if (
                            venue &&
                            venue.area_id
                        ) {

                            area =
                                areaMap[
                                    String(
                                        venue.area_id
                                    )
                                ] || null;

                        }


                        const offerings =
                            offeringsByPlacement[
                                placementId
                            ] || [];


/*
 * Placementに紐づくメニューを生成
 */
const menus =
    offerings.map(
        function ( offering ) {

            const menu =
                menuMap[
                    String(
                        offering.menu_item_id
                    )
                ] || {};


            return {

                /*
                 * どの出店の商品か
                 */
                placementId:
                    placementId,


                menuName:
                    menu.menu_name ||
                    '商品',


                category:
                    menu.item_category ||
                    '',


                /*
                 * 表示価格
                 */
                price:
                    cleanNumber(
                        offering.price
                    ),


                /*
                 * 比較用価格
                 */
                priceValue:
                    toFiniteNumber(
                        offering.price
                    ),


                servingQuantity:
                    cleanNumber(
                        offering
                            .serving_quantity
                    ),


                servingUnit:
                    offering
                        .serving_unit ||
                    '',



                /*
                 * 表示用単位価格
                 */
                unitPrice:
                    getUnitPrice(
                        offering
                    ),


                /*
                 * 比較用単位価格
                 */
                unitPriceValue:
                    getUnitPriceValue(
                        offering
                    ),


                availability:
                    formatAvailability(
                        offering
                            .availability
                    ),


                verification:
                    formatVerification(
                        offering
                            .verification_status
                    ),


                isLowestPrice:
                    false,


                isLowestUnitPrice:
                    false

            };

        }
    );


return {

    placementId:
        placementId,

    placement:
        placement,

    stall:
        stall,

    festival:
        festival,

    venue:
        venue,

    area:
        area,

    menus:
        menus

};

}
);
                        

/* =====================================
 * 最安価格・最安単位価格
 *
 * 「同じ商品名 + 同じ単位」
 * の商品だけを比較する
 * ===================================== */

function markBestPrices(
    compareData
) {

    const allMenus = [];


    /* =================================
     * 比較文字列を正規化
     *
     * 例:
     * "たこ焼き"
     * " たこ焼き "
     *
     * を同じものとして扱う
     * ================================= */

    function normalizeCompareText(
        value
    ) {

        if (
            value === undefined ||
            value === null
        ) {
            return '';
        }

        let text =
            String(
                value
            ).trim();

        /*
         * 全角・半角などを可能な範囲で統一
         */
        if (
            typeof text.normalize ===
            'function'
        ) {

            text =
                text.normalize(
                    'NFKC'
                );

        }

        /*
         * 連続空白を1つにする
         */
        text =
            text.replace(
                /\s+/g,
                ' '
            );

        /*
         * 英字商品名にも対応
         */
        text =
            text.toLowerCase();

        return text;

    }


    /* =================================
     * 全メニューを集める
     * ================================= */

    compareData.forEach(
        function ( data ) {

            if (
                !data.menus ||
                !Array.isArray(
                    data.menus
                )
            ) {
                return;
            }


            data.menus.forEach(
                function ( menu ) {

                    /*
                     * 毎回初期化
                     */
                    menu.isLowestPrice =
                        false;

                    menu.isLowestUnitPrice =
                        false;


                    /*
                     * 比較用の商品名
                     */
                    menu.compareMenuName =
                        normalizeCompareText(
                            menu.menuName
                        );


                    /*
                     * 比較用単位
                     */
                    menu.compareUnit =
                        normalizeCompareText(
                            menu.servingUnit
                        );


                    allMenus.push(
                        menu
                    );

                }
            );

        }
    );


    /* =================================
     * 商品名+単位ごとのグループ
     *
     * 例:
     *
     * たこ焼き + 個
     * 焼きそば + パック
     * りんご飴 + 本
     * ================================= */

    const groups = {};


    allMenus.forEach(
        function ( menu ) {

            /*
             * 商品名が無ければ比較しない
             */
            if (
                !menu.compareMenuName
            ) {
                return;
            }


            /*
             * 単位が無ければ比較しない
             *
             * 「同じ商品名+同じ単位」
             * が条件だから
             */
            if (
                !menu.compareUnit
            ) {
                return;
            }


            const groupKey =
                menu.compareMenuName +
                '||' +
                menu.compareUnit;


            if (
                !groups[
                    groupKey
                ]
            ) {

                groups[
                    groupKey
                ] = [];

            }


            groups[
                groupKey
            ].push(
                menu
            );

        }
    );


    /* =================================
     * グループごとに判定
     * ================================= */

    Object.keys(
        groups
    ).forEach(
        function ( groupKey ) {

            const menus =
                groups[
                    groupKey
                ];


            /* =============================
             * 2出店以上あるか確認
             *
             * 同じ出店内だけの商品比較は
             * 「最安」としない
             * ============================= */

            const placementIds =
                [
                    ...new Set(
                        menus.map(
                            function ( menu ) {

                                return String(
                                    menu.placementId
                                );

                            }
                        )
                    )
                ];


            if (
                placementIds.length < 2
            ) {

                return;

            }


            /* =============================
             * 最安価格
             *
             * 同商品+同単位の
             * 販売価格を比較
             * ============================= */

            const priceCandidates =
                menus.filter(
                    function ( menu ) {

                        return (
                            menu.priceValue !==
                                null &&
                            Number.isFinite(
                                menu.priceValue
                            )
                        );

                    }
                );


            /*
             * 価格が登録されている
             * 出店が2件以上あるか
             */
            const pricePlacementIds =
                [
                    ...new Set(
                        priceCandidates.map(
                            function ( menu ) {

                                return String(
                                    menu.placementId
                                );

                            }
                        )
                    )
                ];


            if (
                pricePlacementIds.length >= 2
            ) {

                const lowestPrice =
                    Math.min.apply(
                        null,
                        priceCandidates.map(
                            function ( menu ) {

                                return menu
                                    .priceValue;

                            }
                        )
                    );


                priceCandidates.forEach(
                    function ( menu ) {

                        /*
                         * 円なので通常整数だが
                         * 小数にも一応対応
                         */
                        if (
                            Math.abs(
                                menu.priceValue -
                                lowestPrice
                            ) <
                            0.000001
                        ) {

                            menu.isLowestPrice =
                                true;

                        }

                    }
                );

            }


            /* =============================
             * 最安単位価格
             *
             * 同商品+同単位で
             * price / quantity を比較
             * ============================= */

            const unitPriceCandidates =
                menus.filter(
                    function ( menu ) {

                        return (
                            menu.unitPriceValue !==
                                null &&
                            Number.isFinite(
                                menu.unitPriceValue
                            )
                        );

                    }
                );


            const unitPricePlacementIds =
                [
                    ...new Set(
                        unitPriceCandidates.map(
                            function ( menu ) {

                                return String(
                                    menu.placementId
                                );

                            }
                        )
                    )
                ];


            if (
                unitPricePlacementIds.length >= 2
            ) {

                const lowestUnitPrice =
                    Math.min.apply(
                        null,
                        unitPriceCandidates.map(
                            function ( menu ) {

                                return menu
                                    .unitPriceValue;

                            }
                        )
                    );


                unitPriceCandidates.forEach(
                    function ( menu ) {

                        /*
                         * 割り算による
                         * 浮動小数誤差対策
                         */
                        if (
                            Math.abs(
                                menu.unitPriceValue -
                                lowestUnitPrice
                            ) <
                            0.000001
                        ) {

                            menu.isLowestUnitPrice =
                                true;

                        }

                    }
                );

            }

        }
    );

}

/* =====================================
 * 商品別比較サマリー
 *
 * 同じ商品名 + 同じ単位でグループ化
 * ===================================== */

function renderProductGroupSummary(
    compareData
) {

    const comparePage =
        document.getElementById(
            'stall-compare-page'
        );

    if (
        !comparePage
    ) {
        return;
    }


    /* =================================
     * 文字列正規化
     * ================================= */

    function normalizeText(
        value
    ) {

        if (
            value === undefined ||
            value === null
        ) {
            return '';
        }

        let text =
            String(
                value
            ).trim();


        if (
            typeof text.normalize ===
            'function'
        ) {

            text =
                text.normalize(
                    'NFKC'
                );

        }


        text =
            text.replace(
                /\s+/g,
                ' '
            );

        return text;

    }

/* =================================
 * 屋台ページリンクを生成
 * ================================= */

function appendStallLinks(
    container,
    items
) {

    const stalls = [];
    const seen = {};


    items.forEach(
        function ( item ) {

            /*
             * page_nameがある場合は
             * page_nameで重複判定
             *
             * 無い場合は名前で判定
             */
            const key =
                item.stallPage
                    ? 'page:' +
                      item.stallPage
                    : 'name:' +
                      item.stallName;


            if (
                seen[
                    key
                ]
            ) {
                return;
            }


            seen[
                key
            ] = true;


            stalls.push(
                {
                    name:
                        item.stallName,

                    page:
                        item.stallPage
                }
            );

        }
    );


    stalls.forEach(
        function (
            stall,
            index
        ) {

            /*
             * 2件目以降の区切り
             */
            if (
                index > 0
            ) {

                container.appendChild(
                    document.createTextNode(
                        '・'
                    )
                );

            }


            /*
             * ページが存在する場合
             * リンクにする
             */
            if (
                stall.page
            ) {

                const link =
                    document.createElement(
                        'a'
                    );

                link.href =
                    mw.util.getUrl(
                        stall.page
                    );

                link.textContent =
                    stall.name;

                link.className =
                    'stall-product-group-stall-link';


                container.appendChild(
                    link
                );

            } else {

                /*
                 * page_nameが取得できない場合
                 * 普通の文字として表示
                 */
                container.appendChild(
                    document.createTextNode(
                        stall.name
                    )
                );

            }

        }
    );

}

    /* =================================
     * 商品グループ作成
     * ================================= */

    const groups = {};


    compareData.forEach(
        function ( data ) {

            if (
                !data.menus ||
                !Array.isArray(
                    data.menus
                )
            ) {
                return;
            }


            data.menus.forEach(
                function ( menu ) {

                    const menuName =
                        normalizeText(
                            menu.menuName
                        );

                    const unit =
                        normalizeText(
                            menu.servingUnit
                        );


                    /*
                     * 商品名または単位が無いものは
                     * 商品比較サマリーから除外
                     */
                    if (
                        !menuName ||
                        !unit
                    ) {
                        return;
                    }


                    const key =
                        menuName.toLowerCase() +
                        '||' +
                        unit.toLowerCase();


                    if (
                        !groups[
                            key
                        ]
                    ) {

                        groups[
                            key
                        ] = {

                            menuName:
                                menuName,

                            unit:
                                unit,

                            items:
                                []

                        };

                    }


                    groups[
    key
].items.push(
    {

        placementId:
            String(
                menu.placementId
            ),

        stallName:
            (
                data.stall &&
                data.stall.stall_name
            )
                ? data.stall.stall_name
                : '屋台',

        /*
         * 屋台ページ名
         */
        stallPage:
            (
                data.stall &&
                data.stall.page_name
            )
                ? data.stall.page_name
                : '',

        menu:
            menu

    }
);

                }
            );

        }
    );


    const groupKeys =
        Object.keys(
            groups
        );


    if (
        groupKeys.length === 0
    ) {
        return;
    }


    /* =================================
     * サマリー全体
     * ================================= */

    const summary =
        document.createElement(
            'section'
        );

    summary.className =
        'stall-product-group-summary';


    const title =
        document.createElement(
            'h2'
        );

    title.className =
        'stall-product-group-summary-title';

    title.textContent =
        '商品別比較サマリー';


    summary.appendChild(
        title
    );


    /* =================================
     * 各商品グループ
     * ================================= */

    groupKeys.forEach(
        function ( key ) {

            const group =
                groups[
                    key
                ];

            const items =
                group.items;


            /*
             * 同じPlacementを重複カウントしない
             */
            const placementIds =
                [
                    ...new Set(
                        items.map(
                            function ( item ) {

                                return item
                                    .placementId;

                            }
                        )
                    )
                ];


            const card =
                document.createElement(
                    'div'
                );

            card.className =
                'stall-product-group-card';


            /* =============================
             * 商品名
             * ============================= */

            const heading =
                document.createElement(
                    'h3'
                );

            heading.className =
                'stall-product-group-name';

            heading.textContent =
                group.menuName +
                ' / ' +
                group.unit;


            card.appendChild(
                heading
            );


            /* =============================
             * 比較店舗数
             * ============================= */

            const count =
                document.createElement(
                    'div'
                );

            count.className =
                'stall-product-group-count';

            count.textContent =
                '比較店舗:' +
                placementIds.length +
                '店';


            card.appendChild(
                count
            );

/* =============================
 * 対象店舗リンク
 * ============================= */

const stallList =
    document.createElement(
        'div'
    );

stallList.className =
    'stall-product-group-stalls';


const stallListLabel =
    document.createElement(
        'span'
    );

stallListLabel.className =
    'stall-product-group-label';

stallListLabel.textContent =
    '対象店舗:';


stallList.appendChild(
    stallListLabel
);


/*
 * 屋台名をリンクとして追加
 */
appendStallLinks(
    stallList,
    items
);


card.appendChild(
    stallList
);

            /* =============================
             * 1店舗しかない場合
             * ============================= */

            if (
                placementIds.length < 2
            ) {

                const notice =
                    document.createElement(
                        'div'
                    );

                notice.className =
                    'stall-product-group-notice';

                notice.textContent =
                    '比較対象が1店舗のみです。';


                card.appendChild(
                    notice
                );

            }


            /* =============================
             * 最安価格の商品
             * ============================= */

            const lowestPriceItems =
                items.filter(
                    function ( item ) {

                        return (
                            item.menu
                                .isLowestPrice ===
                            true
                        );

                    }
                );


            if (
                lowestPriceItems.length > 0
            ) {

                const lowestPrice =
                    lowestPriceItems[
                        0
                    ].menu.priceValue;


                const row =
                    document.createElement(
                        'div'
                    );

                row.className =
                    'stall-product-group-best';


                const label =
                    document.createElement(
                        'span'
                    );

                label.className =
                    'stall-product-group-label';

                label.textContent =
                    '最安価格:';


                const value =
                    document.createElement(
                        'strong'
                    );

                value.textContent =
                    lowestPrice +
                    '円';


                row.appendChild(
                    label
                );

                row.appendChild(
                    value
                );


                card.appendChild(
                    row
                );


                /*
 * 最安店舗リンク
 */
const shopRow =
    document.createElement(
        'div'
    );

shopRow.className =
    'stall-product-group-shop';


const shopLabel =
    document.createElement(
        'span'
    );

shopLabel.className =
    'stall-product-group-label';

shopLabel.textContent =
    '最安:';


shopRow.appendChild(
    shopLabel
);


/*
 * 最安店舗をリンク表示
 */
appendStallLinks(
    shopRow,
    lowestPriceItems
);


card.appendChild(
    shopRow
);

            }


            /* =============================
             * 最安単位価格
             * ============================= */

            const lowestUnitItems =
                items.filter(
                    function ( item ) {

                        return (
                            item.menu
                                .isLowestUnitPrice ===
                            true
                        );

                    }
                );


            if (
                lowestUnitItems.length > 0
            ) {

                const unitPrice =
                    lowestUnitItems[
                        0
                    ].menu.unitPriceValue;


                /*
                 * 小数表示調整
                 */
                const displayUnitPrice =
                    Math.round(
                        unitPrice *
                        100
                    ) /
                    100;


                const row =
                    document.createElement(
                        'div'
                    );

                row.className =
                    'stall-product-group-best-unit';


                const label =
                    document.createElement(
                        'span'
                    );

                label.className =
                    'stall-product-group-label';

                label.textContent =
                    '最安単位価格:';


                const value =
                    document.createElement(
                        'strong'
                    );

                value.textContent =
                    displayUnitPrice +
                    '円/' +
                    group.unit;


                row.appendChild(
                    label
                );

                row.appendChild(
                    value
                );


                card.appendChild(
                    row
                );

            }


            summary.appendChild(
                card
            );

        }
    );


    /*
     * 比較表の一番上へ追加
     */
    comparePage.insertBefore(
        summary,
        comparePage.firstChild
    );

}

/* =================================
 * 最安値を自動判定
 * ================================= */

markBestPrices(
    compareData
);


renderComparison(
    compareData
);


/*
 * 詳細比較表を描画した後に
 * 商品別サマリーを追加
 */
renderProductGroupSummary(
    compareData
);

        }
    ).catch(
        function ( error ) {

            console.error(
                'Placement比較データ取得エラー:',
                error
            );


            renderMessage(
                '比較データの取得中にエラーが発生しました。'
            );

        }
    );



} );

$(function () {
const statusLabels = {
    active: '出店中・出店予定',
    cancelled: '出店中止',
    unknown: '未確認'
};

    const statusSelect = document.querySelector(
        'select[name="FestivalStallPlacement[status]"]'
    );

    if (statusSelect) {
        Array.from(statusSelect.options).forEach(function (option) {
            if (statusLabels[option.value]) {
                option.textContent = statusLabels[option.value];
            }
        });
    }

    const verificationLabels = {
        verified: '確認済み',
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };

    const verificationSelect = document.querySelector(
        'select[name="FestivalStallPlacement[verification_status]"]'
    );

    if (verificationSelect) {
        Array.from(verificationSelect.options).forEach(function (option) {
            if (verificationLabels[option.value]) {
                option.textContent = verificationLabels[option.value];
            }
        });
    }
    
    const yearInput = document.querySelector(
    'input[name="FestivalStallPlacement[year]"]'
);

if (yearInput) {
    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;

    const validateYear = function () {
        const value = yearInput.value.trim();

        if (value !== '' && !/^\d{4}$/.test(value)) {
            yearInput.setCustomValidity(
                '開催年は4桁の数字で入力してください(例:2026)'
            );
        } else {
            yearInput.setCustomValidity('');
        }
    };

    yearInput.addEventListener('input', validateYear);
    yearInput.addEventListener('change', validateYear);
    yearInput.addEventListener('invalid', validateYear);

    validateYear();
}
    
const positionLabels = {
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};

const positionSelect = document.querySelector(
    'select[name="FestivalStallPlacement[position_status]"]'
);

if (positionSelect) {
    Array.from(positionSelect.options).forEach(function (option) {
        if (positionLabels[option.value]) {
            option.textContent = positionLabels[option.value];
        }
    });
}
    
    const accuracyInput = document.querySelector(
    'input[name="FestivalStallPlacement[position_accuracy_m]"]'
);

if (accuracyInput) {
    accuracyInput.inputMode = 'numeric';

    const validateAccuracy = function () {
        const value = accuracyInput.value.trim();

        if (value !== '' && !/^\d+$/.test(value)) {
            accuracyInput.setCustomValidity(
                '位置精度は0以上の整数で入力してください(例:10)'
            );
        } else {
            accuracyInput.setCustomValidity('');
        }
    };

    accuracyInput.addEventListener('input', validateAccuracy);
    accuracyInput.addEventListener('change', validateAccuracy);
    accuracyInput.addEventListener('invalid', validateAccuracy);

    validateAccuracy();
}
    
    const openingTimeInput = document.querySelector(
    'input[name="FestivalStallPlacement[opening_time]"]'
);

const closingTimeInput = document.querySelector(
    'input[name="FestivalStallPlacement[closing_time]"]'
);

const timePattern = /^([01]\d|2[0-3]):[0-5]\d$/;

function setupTimeValidation(input, label) {
    if (!input) {
        return;
    }

    input.placeholder = '例:10:00';

    const validateTime = function () {
        const value = input.value.trim();

        input.setCustomValidity('');

        if (value !== '' && !timePattern.test(value)) {
            input.setCustomValidity(
                label + 'は24時間表記の HH:MM 形式で入力してください(例:10:00)'
            );
        }
    };

    input.addEventListener('input', validateTime);
    input.addEventListener('change', validateTime);
    input.addEventListener('invalid', validateTime);

    validateTime();
}

setupTimeValidation(openingTimeInput, '営業開始時刻');
setupTimeValidation(closingTimeInput, '営業終了時刻');
    
    const latitudeInput = document.querySelector(
    'input[name="FestivalStallPlacement[latitude]"]'
);

const longitudeInput = document.querySelector(
    'input[name="FestivalStallPlacement[longitude]"]'
);

function setupCoordinateValidation(input, label, min, max) {
    if (!input) {
        return null;
    }

    input.inputMode = 'decimal';

    const validateCoordinate = function () {
        const value = input.value.trim();

        input.setCustomValidity('');

        /*
         * exact または approximate の場合は
         * 緯度・経度を必須にする。
         */
        if (value === '') {
            if (
                positionSelect &&
                (
                    positionSelect.value === 'exact' ||
                    positionSelect.value === 'approximate'
                )
            ) {
                input.setCustomValidity(
                    label +
                    'は「位置確認済み」または「おおよその位置」を選択した場合は必須です。'
                );

                return;
            }

            const otherInput =
                input === latitudeInput
                    ? longitudeInput
                    : latitudeInput;

            if (
                otherInput &&
                otherInput.value.trim() !== ''
            ) {
                input.setCustomValidity(
                    '緯度と経度は両方入力するか、両方空欄にしてください。'
                );
            }

            return;
        }

        /*
         * 数値形式チェック
         */
        if (!/^-?\d+(\.\d+)?$/.test(value)) {
            input.setCustomValidity(
                label + 'は数値で入力してください。'
            );
            return;
        }

        /*
         * 範囲チェック
         */
        const number = Number(value);

        if (number < min || number > max) {
            input.setCustomValidity(
                label +
                'は' +
                min +
                '〜' +
                max +
                'の範囲で入力してください。'
            );
        }
    };

    input.addEventListener(
        'input',
        validateCoordinate
    );

    input.addEventListener(
        'change',
        validateCoordinate
    );

    input.addEventListener(
        'invalid',
        validateCoordinate
    );

    validateCoordinate();

    /*
     * position_status変更時に
     * 再チェックできるよう関数を返す。
     */
    return validateCoordinate;
}

const validateLatitude =
    setupCoordinateValidation(
        latitudeInput,
        '緯度',
        20,
        46
    );

const validateLongitude =
    setupCoordinateValidation(
        longitudeInput,
        '経度',
        122,
        154
    );

/*
 * 一方の座標を変更した場合、
 * 反対側のペア整合性も再検証する。
 */
if (
    latitudeInput &&
    validateLongitude
) {
    latitudeInput.addEventListener(
        'input',
        validateLongitude
    );

    latitudeInput.addEventListener(
        'change',
        validateLongitude
    );
}

if (
    longitudeInput &&
    validateLatitude
) {
    longitudeInput.addEventListener(
        'input',
        validateLatitude
    );

    longitudeInput.addEventListener(
        'change',
        validateLatitude
    );
}

/*
 * 位置情報の状態を変更した場合、
 * 緯度・経度を再検証する。
 */
if (positionSelect) {
    positionSelect.addEventListener(
        'change',
        function () {
            if (validateLatitude) {
                validateLatitude();
            }

            if (validateLongitude) {
                validateLongitude();
            }
        }
    );
}
    
    const sourceUrlInput = document.querySelector(
    'input[name="FestivalStallPlacement[source_url]"]'
);

if (sourceUrlInput) {
    sourceUrlInput.inputMode = 'url';

    const validateSourceUrl = function () {
        const value = sourceUrlInput.value.trim();

        sourceUrlInput.setCustomValidity('');

        if (value === '') {
            return;
        }

        try {
            const url = new URL(value);

            if (url.protocol !== 'http:' && url.protocol !== 'https:') {
                sourceUrlInput.setCustomValidity(
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            sourceUrlInput.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
        }
    };

    sourceUrlInput.addEventListener('input', validateSourceUrl);
    sourceUrlInput.addEventListener('change', validateSourceUrl);
    sourceUrlInput.addEventListener('invalid', validateSourceUrl);

    validateSourceUrl();
}
    
    const sortOrderInput = document.querySelector(
    'input[name="FestivalStallPlacement[sort_order]"]'
);

if (sortOrderInput) {
    sortOrderInput.inputMode = 'numeric';

    const validateSortOrder = function () {
        const value = sortOrderInput.value.trim();

        sortOrderInput.setCustomValidity('');

        if (value !== '' && !/^\d+$/.test(value)) {
            sortOrderInput.setCustomValidity(
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    };

    sortOrderInput.addEventListener('input', validateSortOrder);
    sortOrderInput.addEventListener('change', validateSortOrder);
    sortOrderInput.addEventListener('invalid', validateSortOrder);

    validateSortOrder();
}
    
});

/**
 * FestivalStallPlacement - 最終確認日の未来日チェック
 */
(function () {
	'use strict';

	function setupLastConfirmedValidation() {
		const dateInputs = document.querySelectorAll(
			'input[name="FestivalStallPlacement[last_confirmed]"]'
		);

		dateInputs.forEach(function (dateInput) {
			if (dateInput.dataset.lastConfirmedValidation === '1') {
				return;
			}

			dateInput.dataset.lastConfirmedValidation = '1';

			function getVisibleInput() {
				const widget = dateInput.closest('.oo-ui-widget');

				if (!widget) {
					return null;
				}

				return widget.querySelector('input[type="text"]');
			}

			function getErrorElement() {
				const widget = dateInput.closest('.oo-ui-widget');

				if (!widget) {
					return null;
				}

				let error = widget.parentNode.querySelector(
					'.stall-last-confirmed-error'
				);

				if (!error) {
					error = document.createElement('div');
					error.className = 'stall-last-confirmed-error';
					error.setAttribute('role', 'alert');
					error.hidden = true;

					widget.insertAdjacentElement('afterend', error);
				}

				return error;
			}

			function showError() {
				const visibleInput = getVisibleInput();
				const error = getErrorElement();

				if (!visibleInput || !error) {
					return;
				}

				let message;

				if (dateInput.validity.rangeOverflow) {
					const maxDate = dateInput.max.replace(/-/g, '/');

					message =
						'未来の日付は入力できません。' +
						maxDate +
						'以前の日付を入力してください。';
				} else {
					message =
						dateInput.validationMessage ||
						'正しい日付を入力してください。';
				}

				error.textContent = message;
				error.hidden = false;

				visibleInput.setAttribute('aria-invalid', 'true');
			}

			function clearError() {
				const visibleInput = getVisibleInput();
				const error = getErrorElement();

				if (error) {
					error.hidden = true;
					error.textContent = '';
				}

				if (visibleInput) {
					visibleInput.removeAttribute('aria-invalid');
				}
			}

			/*
			 * 非表示の date input に対する
			 * ブラウザ標準エラー表示を止める。
			 */
			dateInput.addEventListener('invalid', function (event) {
				event.preventDefault();

				showError();

				const visibleInput = getVisibleInput();

				if (visibleInput) {
					window.setTimeout(function () {
						visibleInput.focus();
					}, 0);
				}
			});

			/*
			 * ユーザーが日付を修正したら
			 * 有効になった時点でエラーを消す。
			 */
const form = dateInput.form;

if (form) {
    function handleDateChange(event) {
        const currentWidget =
            dateInput.closest('.oo-ui-widget');

        if (
            !currentWidget ||
            !currentWidget.contains(event.target)
        ) {
            return;
        }

        window.setTimeout(function () {
            if (dateInput.validity.valid) {
                clearError();
            } else if (
                dateInput.validity.rangeOverflow
            ) {
                showError();
            }
        }, 0);
    }

    form.addEventListener(
        'input',
        handleDateChange
    );

    form.addEventListener(
        'change',
        handleDateChange
    );

    /*
     * Page Forms のカレンダー選択では
     * visible input に blur が発生する。
     * blur は通常バブルしないため capture=true。
     */
    form.addEventListener(
        'blur',
        handleDateChange,
        true
    );
}
		});
	}

	if (document.readyState === 'loading') {
		document.addEventListener(
			'DOMContentLoaded',
			setupLastConfirmedValidation
		);
	} else {
		setupLastConfirmedValidation();
	}

	mw.hook('wikipage.content').add(function () {
		setupLastConfirmedValidation();
	});
})();

/**
 * FestivalStallPlacement
 * Cargo既存レコード候補警告 V2
 *
 * 同じ festival + year + venue + stall があれば
 * 警告と既存ページへのリンクを表示する。
 * 保存自体は禁止しない。
 */
mw.loader.using([
	'mediawiki.api',
	'mediawiki.util'
]).then(function () {
	'use strict';

	const api = new mw.Api();

	function setupDuplicateWarning() {
		const form = document.getElementById('pfForm');

		if (!form) {
			return;
		}

		if (form.dataset.duplicateWarningV2 === '1') {
			return;
		}

		const table = form.querySelector('.formtable');

		if (!table) {
			return;
		}

		form.dataset.duplicateWarningV2 = '1';

		const warning = document.createElement('div');

		warning.className = 'stall-duplicate-warning';
		warning.setAttribute('role', 'status');
		warning.hidden = true;

		/*
		 * 表の中ではなく、表の直前に置く。
		 * 警告表示でフォームの列幅を崩さない。
		 */
		table.insertAdjacentElement('beforebegin', warning);

		let timer = null;
		let requestId = 0;

		function escapeCargo(value) {
			return String(value).replace(/'/g, "''");
		}

		function getField(name) {
			return form.querySelector(
				'[name="FestivalStallPlacement[' +
				name +
				']"]'
			);
		}

		function cargoQuery(tableName, fields, where, limit) {
			return api.get({
				action: 'cargoquery',
				tables: tableName,
				fields: fields,
				where: where,
				limit: limit || 50,
				format: 'json'
			}).then(function (data) {
				if (
					!data ||
					!Array.isArray(data.cargoquery)
				) {
					return [];
				}

				return data.cargoquery.map(function (item) {
					return item.title || item;
				});
			});
		}

		function resolveId(
			tableName,
			idField,
			nameField,
			value
		) {
			if (!value) {
				return Promise.resolve(null);
			}

			if (/^\d+$/.test(value)) {
				return Promise.resolve(value);
			}

			return cargoQuery(
				tableName,
				idField + '=resolved_id',
				nameField +
					"='" +
					escapeCargo(value) +
					"'",
				2
			).then(function (rows) {
				if (rows.length !== 1) {
					console.warn(
						'IDを一意に取得できません:',
						tableName,
						value,
						rows
					);

					return null;
				}

				return String(rows[0].resolved_id);
			});
		}

		function clearWarning() {
			warning.hidden = true;
			warning.replaceChildren();
		}

		function showFailure() {
			warning.replaceChildren();

			const text = document.createElement('div');

			text.textContent =
				'既存データの確認に失敗しました。' +
				'登録はできますが、重複がないかご確認ください。';

			warning.appendChild(text);
			warning.hidden = false;
		}

function showCandidates(rows, venueSpecified) {
	warning.replaceChildren();

const positionLabels = {
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};

	const verificationLabels = {
		verified: '確認済み',
		partially_verified: '一部確認済み',
		unverified: '未確認',
		outdated: '情報が古い可能性あり'
	};
	
const statusLabels = {
    active: '出店中・出店予定',
    cancelled: '出店中止',
    unknown: '未確認'
};

	function displayValue(value, fallback) {
		if (
			value === undefined ||
			value === null ||
			String(value).trim() === ''
		) {
			return fallback || '未確認';
		}

		return String(value);
	}

	function addDetail(container, label, value) {
		const row = document.createElement('div');
		row.className =
			'stall-duplicate-candidate-detail';

		const labelElement =
			document.createElement('span');

		labelElement.className =
			'stall-duplicate-candidate-label';

		labelElement.textContent = label;

		const valueElement =
			document.createElement('span');

		valueElement.className =
			'stall-duplicate-candidate-value';

		valueElement.textContent = value;

		row.appendChild(labelElement);
		row.appendChild(valueElement);

		container.appendChild(row);
	}

	const title = document.createElement('strong');

	title.className =
		'stall-duplicate-warning-title';

	title.textContent =
		venueSpecified
			? (
				'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
				rows.length +
				'件あります。'
			)
			: (
				'⚠ 同じ祭り・開催年・屋台の既存データが' +
				rows.length +
				'件あります。'
			);

	warning.appendChild(title);

	const description =
		document.createElement('p');

	description.className =
		'stall-duplicate-warning-description';

	description.textContent =
		venueSpecified
			? (
				'出店場所が異なる場合は新規登録して構いません。' +
				'下の既存データと同じ場所ではないか確認してください。'
			)
			: (
				'会場未指定のため、会場を問わず候補を確認しています。' +
				'出店場所が異なる場合は新規登録して構いません。' +
				'下の既存データと同じ場所ではないか確認してください。'
			);

	warning.appendChild(description);

	const list = document.createElement('div');

	list.className =
		'stall-duplicate-candidate-list';

	/*
	 * placement_id順に並べる
	 */
	rows.sort(function (a, b) {
		return (
			Number(a.placement_id) -
			Number(b.placement_id)
		);
	});

	rows.forEach(function (row) {
		const card =
			document.createElement('div');

		card.className =
			'stall-duplicate-candidate';

		/*
		 * カード見出し
		 */
		const header =
			document.createElement('div');

		header.className =
			'stall-duplicate-candidate-header';

const heading =
	document.createElement('strong');

heading.textContent =
	'既存の出店情報';

header.appendChild(heading);

		card.appendChild(header);

		/*
		 * 出店場所
		 */
		addDetail(
			card,
			'出店場所',
			displayValue(
				row.location_note,
				'場所メモなし'
			)
		);

		/*
		 * 位置状態
		 */
		addDetail(
			card,
			'位置状態',
			positionLabels[
				row.position_status
			] ||
			displayValue(
				row.position_status,
				'位置未確認'
			)
		);

		/*
		 * 緯度・経度
		 */
		let coordinates =
			'位置情報なし';

		if (
			row.latitude !== undefined &&
			row.latitude !== null &&
			String(row.latitude).trim() !== '' &&
			row.longitude !== undefined &&
			row.longitude !== null &&
			String(row.longitude).trim() !== ''
		) {
			coordinates =
				String(row.latitude) +
				', ' +
				String(row.longitude);
		}

		addDetail(
			card,
			'緯度・経度',
			coordinates
		);

/*
 * 位置精度
 */
let accuracy = '未確認';

if (
	row.position_accuracy_m !== undefined &&
	row.position_accuracy_m !== null &&
	String(row.position_accuracy_m).trim() !== ''
) {
	accuracy =
		String(row.position_accuracy_m) +
		' m';
}

addDetail(
	card,
	'位置精度',
	accuracy
);

/*
 * 出店状態
 */
addDetail(
	card,
	'出店状態',
	statusLabels[
		row.status
	] ||
	displayValue(
		row.status,
		'未確認'
	)
);

/*
 * 最終確認日
 */
let lastConfirmed = '未確認';

if (
	row.last_confirmed !== undefined &&
	row.last_confirmed !== null &&
	String(row.last_confirmed).trim() !== ''
) {
	lastConfirmed =
		String(row.last_confirmed)
			.replace(/-/g, '/');
}

addDetail(
	card,
	'最終確認日',
	lastConfirmed
);

		/*
		 * 確認状態
		 */
		addDetail(
			card,
			'確認状態',
			verificationLabels[
				row.verification_status
			] ||
			displayValue(
				row.verification_status,
				'未確認'
			)
		);

		/*
		 * 既存ページへのリンク
		 */
		const actions =
			document.createElement('div');

		actions.className =
			'stall-duplicate-candidate-actions';

		const link =
			document.createElement('a');

		link.href =
			mw.util.getUrl(row.page_name);

		link.target = '_blank';
		link.rel = 'noopener';

		link.textContent =
			'既存データを確認';

		actions.appendChild(link);
		card.appendChild(actions);

		list.appendChild(card);
	});

	warning.appendChild(list);

	const footer =
		document.createElement('div');

	footer.className =
		'stall-duplicate-warning-footer';

	footer.textContent =
		'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';

	warning.appendChild(footer);

	warning.hidden = false;
}

function checkDuplicates() {
	const currentRequest = ++requestId;

	/*
	 * 毎回現在のinput/selectを取得する。
	 * Page Formsが要素を作り直しても対応できる。
	 */
	const stall = getField('stall_id');
	const festival = getField('festival_id');
	const venue = getField('venue_id');
	const year = getField('year');

	if (
		!stall ||
		!festival ||
		!venue ||
		!year
	) {
		clearWarning();
		return;
	}

	const stallValue = stall.value.trim();
	const festivalValue = festival.value.trim();
	const venueValue = venue.value.trim();
	const yearValue = year.value.trim();

	if (
		!stallValue ||
		!festivalValue ||
		!/^\d{4}$/.test(yearValue)
	) {
		clearWarning();
		return;
	}

	/*
	 * async / await は使わず、
	 * Promise の then() で処理する。
	 */
	Promise.all([
		resolveId(
			'Stalls',
			'stall_id',
			'name',
			stallValue
		),
		resolveId(
			'Festivals',
			'festival_id',
			'name',
			festivalValue
		),
		venueValue !== ''
			? resolveId(
				'Venues',
				'venue_id',
				'_pageName',
				venueValue
			)
			: Promise.resolve(null)
	])
	.then(function (ids) {
		if (currentRequest !== requestId) {
			return null;
		}

		if (
			!ids[0] ||
			!ids[1] ||
			(
				venueValue !== '' &&
				!ids[2]
			)
		) {
			clearWarning();
			return null;
		}

		let where =
			'festival_id=' +
			ids[1] +
			' AND year=' +
			yearValue +
			' AND stall_id=' +
			ids[0];

		if (ids[2]) {
			where +=
				' AND venue_id=' +
				ids[2];
		}

return cargoQuery(
	'FestivalStallPlacements',
	'placement_id=placement_id,' +
		'location_note=location_note,' +
		'latitude=latitude,' +
		'longitude=longitude,' +
		'position_status=position_status,' +
		'position_accuracy_m=position_accuracy_m,' +
		'status=status,' +
		'verification_status=verification_status,' +
		'last_confirmed=last_confirmed,' +
		'_pageName=page_name',
	where,
	50
).then(function (rows) {
			if (currentRequest !== requestId) {
				return;
			}

			console.log(
				'FestivalStallPlacement候補:',
				where,
				rows
			);

			if (rows.length === 0) {
				clearWarning();
				return;
			}

			/*
			 * 今回は警告のみ。
			 * 同条件の既存データをすべて表示する。
			 */
			const rawPageName =
				String(
					mw.config.get('wgPageName') ||
					''
				);

			const formEditMarker =
				'/FestivalStallPlacement/';

			const markerIndex =
				rawPageName.indexOf(
					formEditMarker
				);

			const currentPlacementPage =
				markerIndex >= 0
					? rawPageName
						.slice(
							markerIndex +
							formEditMarker.length
						)
						.replace(/_/g, ' ')
						.trim()
					: '';

			const filteredRows =
				currentPlacementPage
					? rows.filter(function (row) {
						return (
							String(
								row.page_name ||
								''
							)
								.replace(/_/g, ' ')
								.trim() !==
							currentPlacementPage
						);
					})
					: rows;

			if (filteredRows.length === 0) {
				clearWarning();
				return;
			}

			showCandidates(
				filteredRows,
				venueValue !== ''
			);
		});
	})
	.catch(function (error) {
		console.error(
			'FestivalStallPlacement候補確認エラー:',
			error
		);

		showFailure();
	});
}

		function scheduleCheck() {
			window.clearTimeout(timer);

			timer = window.setTimeout(
				checkDuplicates,
				300
			);
		}

		/*
		 * form自身へイベントを設定する。
		 * dropdownが後から置き換わっても拾える。
		 */
		form.addEventListener('change', function (event) {
			const name = event.target.name || '';

			if (
				name ===
					'FestivalStallPlacement[stall_id]' ||
				name ===
					'FestivalStallPlacement[festival_id]' ||
				name ===
					'FestivalStallPlacement[venue_id]' ||
				name ===
					'FestivalStallPlacement[year]'
			) {
				scheduleCheck();
			}
		});

		form.addEventListener('input', function (event) {
			if (
				event.target.name ===
				'FestivalStallPlacement[year]'
			) {
				scheduleCheck();
			}
		});

		scheduleCheck();
	}

	if (document.readyState === 'loading') {
		document.addEventListener(
			'DOMContentLoaded',
			setupDuplicateWarning
		);
	} else {
		setupDuplicateWarning();
	}

	mw.hook('pf.formSetupAfter').add(
		setupDuplicateWarning
	);
});

/*
 * FestivalStallMenuOffering
 * 入力検証・日本語表示
 */
(function () {
    'use strict';

    var FORM_ID = 'pfForm';

    var availabilityLabels = {
        available: '販売中',
        unknown: '未確認'
    };

    var verificationLabels = {
        verified: '確認済み',
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };

    function isOfferingField(element) {
        return !!(
            element &&
            element.name &&
            element.name.indexOf(
                'FestivalStallMenuOffering['
            ) === 0
        );
    }

    function isTemplateField(element) {
        return !!(
            element &&
            element.name &&
            element.name.indexOf('[num]') !== -1
        );
    }

    function fieldNameEndsWith(element, suffix) {
        return !!(
            element &&
            element.name &&
            element.name.slice(-suffix.length) === suffix
        );
    }

    function localizeSelect(select, labels) {
        if (!select) {
            return;
        }

        Array.from(select.options).forEach(
            function (option) {
                if (
                    Object.prototype.hasOwnProperty.call(
                        labels,
                        option.value
                    ) &&
                    option.textContent !==
                        labels[option.value]
                ) {
                    option.textContent =
                        labels[option.value];
                }
            }
        );
    }

    function validatePrice(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '価格は0以上の整数で入力してください(例:600)'
            );
        }
    }

    function validateServingQuantity(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (value === '') {
            return;
        }

        if (
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
        ) {
            input.setCustomValidity(
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
            );
        }
    }

    function validateLimitedQuantity(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '限定数量は0以上の整数で入力してください(例:100)'
            );
        }
    }

    function validateSortOrder(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    }

    function validateSourceUrl(input) {
        var value = input.value.trim();

        input.setCustomValidity('');

        if (value === '') {
            return;
        }

        try {
            var url = new URL(value);

            if (
                url.protocol !== 'http:' &&
                url.protocol !== 'https:'
            ) {
                input.setCustomValidity(
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            input.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
        }
    }

    function validateLastConfirmed(input) {
        var value = input.value;
        var max = input.max;

        input.setCustomValidity('');

        if (
            value !== '' &&
            max !== '' &&
            value > max
        ) {
            input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
    }

    function getVisibleDateInput(dateInput) {
        var widget =
            dateInput.closest('.oo-ui-widget');

        if (!widget) {
            return null;
        }

        return widget.querySelector(
            'input[type="text"]'
        );
    }

    function getDateErrorElement(dateInput) {
        var widget =
            dateInput.closest('.oo-ui-widget');

        if (!widget) {
            return null;
        }

        var next =
            widget.nextElementSibling;

        if (
            next &&
            next.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            return next;
        }

        var error =
            document.createElement('div');

        /*
         * 既存の最終確認日エラー用CSSも利用する。
         */
        error.className =
            'stall-last-confirmed-error ' +
            'stall-offering-last-confirmed-error';

        error.setAttribute(
            'role',
            'alert'
        );

        error.hidden = true;

        widget.insertAdjacentElement(
            'afterend',
            error
        );

        return error;
    }

    function showDateError(dateInput) {
        var visibleInput =
            getVisibleDateInput(dateInput);

        var error =
            getDateErrorElement(dateInput);

        if (!error) {
            return;
        }

        var maxDate =
            dateInput.max
                ? dateInput.max.replace(/-/g, '/')
                : '';

        if (
            dateInput.validity.rangeOverflow ||
            (
                dateInput.value &&
                dateInput.max &&
                dateInput.value > dateInput.max
            )
        ) {
            error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
            error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }

        error.hidden = false;

        if (visibleInput) {
            visibleInput.setAttribute(
                'aria-invalid',
                'true'
            );
        }
    }

    function clearDateError(dateInput) {
        var visibleInput =
            getVisibleDateInput(dateInput);

        var widget =
            dateInput.closest('.oo-ui-widget');

        var error = null;

        if (
            widget &&
            widget.nextElementSibling &&
            widget.nextElementSibling.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            error =
                widget.nextElementSibling;
        }

        if (error) {
            error.hidden = true;
            error.textContent = '';
        }

        if (visibleInput) {
            visibleInput.removeAttribute(
                'aria-invalid'
            );
        }
    }

    function getLimitedQuantityInput(
        checkbox,
        form
    ) {
        if (!checkbox || !checkbox.name) {
            return null;
        }

        var quantityName =
            checkbox.name.replace(
                /\[limited\]\[value\]$/,
                '[limited_quantity]'
            );

        return Array.from(
            form.querySelectorAll(
                'input[name^="FestivalStallMenuOffering["]'
            )
        ).find(
            function (input) {
                return input.name === quantityName;
            }
        ) || null;
    }

    function updateLimitedState(
        checkbox,
        form,
        clearWhenOff
    ) {
        var quantityInput =
            getLimitedQuantityInput(
                checkbox,
                form
            );

        if (!quantityInput) {
            return;
        }

        if (checkbox.checked) {
            quantityInput.disabled = false;
            quantityInput.removeAttribute(
                'aria-disabled'
            );
        } else {
            if (clearWhenOff) {
                quantityInput.value = '';
            }

            quantityInput.setCustomValidity('');
            quantityInput.disabled = true;
            quantityInput.setAttribute(
                'aria-disabled',
                'true'
            );
        }
    }

    function validateField(element) {
        if (
            !isOfferingField(element) ||
            isTemplateField(element)
        ) {
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[price]'
            )
        ) {
            validatePrice(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[serving_quantity]'
            )
        ) {
            validateServingQuantity(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[limited_quantity]'
            )
        ) {
            validateLimitedQuantity(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[source_url]'
            )
        ) {
            validateSourceUrl(element);
            return;
        }

        if (
            fieldNameEndsWith(
                element,
                '[last_confirmed]'
            )
        ) {
            validateLastConfirmed(element);

            if (element.validity.valid) {
                clearDateError(element);
            }

            return;
        }
    }

    function initializeFields(form) {
        /*
         * 販売状態を日本語化。
         * [num]も変更しておくことで、
         * 後から追加されるmultipleにも反映される。
         */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[availability]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    availabilityLabels
                );
            }
        );

        /*
         * 確認状態を日本語化。
         */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[verification_status]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    verificationLabels
                );
            }
        );

        /*
         * 数値入力向けキーボード。
         */
        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[price]"],' +
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited_quantity]"],' +
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[sort_order]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'numeric';
            }
        );

        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[serving_quantity]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'decimal';
            }
        );

        form.querySelectorAll(
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[source_url]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'url';
            }
        );

        /*
         * 限定数量欄のON/OFF。
         */
        form.querySelectorAll(
            'input[type="checkbox"]' +
            '[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited][value]"]'
        ).forEach(
            function (checkbox) {
                updateLimitedState(
                    checkbox,
                    form,
                    false
                );
            }
        );

        /*
         * 現在値を一度検証。
         * [num]は除外。
         */
        form.querySelectorAll(
            '[name^="FestivalStallMenuOffering["]'
        ).forEach(
            function (element) {
                validateField(element);
            }
        );
    }

    function setupOfferingValidation() {
        var form =
            document.getElementById(
                FORM_ID
            );

        if (!form) {
            return;
        }

        /*
         * wikipage.content 等で再度呼ばれても
         * イベントを二重登録しない。
         */
        if (
            form.dataset
                .offeringValidationInitialized ===
            '1'
        ) {
            initializeFields(form);
            return;
        }

        form.dataset
            .offeringValidationInitialized =
            '1';

        /*
         * multipleで後から追加された項目にも効くよう
         * form側でイベント委譲。
         */
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
            }
        );

        form.addEventListener(
            'change',
            function (event) {
                var target =
                    event.target;

                if (!isOfferingField(target)) {
                    return;
                }

                if (
                    target.type === 'checkbox' &&
                    fieldNameEndsWith(
                        target,
                        '[limited][value]'
                    )
                ) {
                    updateLimitedState(
                        target,
                        form,
                        true
                    );
                }

                validateField(target);
            }
        );

/*
 * Page Forms のカレンダー選択では、
 * visible input に blur が発生する場合がある。
 * 対応する非表示 date input を取得して再検証する。
 */
form.addEventListener(
    'blur',
    function (event) {
var target = event.target;

if (
    !target ||
    typeof target.closest !== 'function'
) {
    return;
}

var widget =
    target.closest('.oo-ui-widget');

        if (!widget) {
            return;
        }

        var dateInput =
            widget.querySelector(
                'input[type="date"]' +
                '[name^="FestivalStallMenuOffering["]' +
                '[name$="[last_confirmed]"]'
            );

        if (
            !dateInput ||
            isTemplateField(dateInput)
        ) {
            return;
        }

        window.setTimeout(
            function () {
                validateField(dateInput);

                if (dateInput.validity.valid) {
                    clearDateError(dateInput);
                } else {
                    showDateError(dateInput);
                }
            },
            0
        );
    },
    true
);

        /*
         * invalidイベントは通常bubbleしないため
         * capture=trueで取得する。
         */
        form.addEventListener(
            'invalid',
            function (event) {
                var target =
                    event.target;

                if (
                    !isOfferingField(target) ||
                    isTemplateField(target)
                ) {
                    return;
                }

                validateField(target);

                if (
                    fieldNameEndsWith(
                        target,
                        '[last_confirmed]'
                    )
                ) {
                    event.preventDefault();

                    showDateError(target);

                    var visibleInput =
                        getVisibleDateInput(
                            target
                        );

                    if (visibleInput) {
                        window.setTimeout(
                            function () {
                                visibleInput.focus();
                            },
                            0
                        );
                    }
                }
            },
            true
        );

        /*
         * 「販売商品を追加」でDOMが増えた場合の初期化。
         */
        var mutationTimer = null;

        var observer =
            new MutationObserver(
                function () {
                    window.clearTimeout(
                        mutationTimer
                    );

                    mutationTimer =
                        window.setTimeout(
                            function () {
                                initializeFields(
                                    form
                                );
                            },
                            100
                        );
                }
            );

        observer.observe(
            form,
            {
                childList: true,
                subtree: true
            }
        );

        initializeFields(form);
    }

    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingValidation
        );
    } else {
        setupOfferingValidation();
    }

    mw.hook(
        'wikipage.content'
    ).add(
        setupOfferingValidation
    );

    mw.hook(
        'pf.formSetupAfter'
    ).add(
        setupOfferingValidation
    );

})();


mw.loader.using('mediawiki.api').then(function () {
    'use strict';

    if (window.__festivalStallMenuFilterInitialized) {
        return;
    }

    window.__festivalStallMenuFilterInitialized = true;

    const STALL_SELECTOR =
        'select[name="FestivalStallPlacement[stall_id]"]';

    const MENU_SELECTOR =
        'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';

    const TEMPLATE_MENU_SELECTOR =
        'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';

    const api = new mw.Api();

    let requestSerial = 0;
    let observerTimer = null;
    let applying = false;

    const menuCache = {};

/*
 * FestivalStallPlacement フォーム以外では
 * この連動機能を起動しない。
 */
const stallSelect =
    document.querySelector(STALL_SELECTOR);

if (!stallSelect) {
    return;
}

/*
 * Page Formsの雛形が持つ全商品optionを最初に保存
 */
const templateSelect =
    document.querySelector(TEMPLATE_MENU_SELECTOR);

if (!templateSelect) {
    console.error(
        '販売商品の雛形SELECTが見つかりません。'
    );
    return;
}

    const masterOptions =
        [...templateSelect.options].map(
            function (option) {
                return option.cloneNode(true);
            }
        );

    function cargoQuote(value) {
        return "'" + String(value)
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }

    function cargoRows(res) {
        return (res.cargoquery || []).map(
            function (row) {
                return row.title || {};
            }
        );
    }

    function getRealMenuSelects() {
        return [
            ...document.querySelectorAll(
                MENU_SELECTOR
            )
        ].filter(function (select) {
            return !select.name.includes('[num]');
        });
    }

    function resolveStallId(stallName) {

        return api.get({
            action: 'cargoquery',
            format: 'json',
            tables: 'Stalls',
            fields:
                'stall_id=stall_id,' +
                'name=name',
            where:
                'name=' +
                cargoQuote(stallName),
            limit: 20
        }).then(function (res) {

            const rows =
                cargoRows(res);

            if (rows.length === 1) {
                return rows[0].stall_id;
            }

            /*
             * 同名表示が
             * 名前 (ID)
             * になっている場合
             */
            const match =
                String(stallName)
                    .match(/\((\d+)\)$/);

            if (!match) {
                throw new Error(
                    '屋台を1件に特定できません: ' +
                    stallName
                );
            }

            return match[1];
        });
    }

    function loadMenus(stallId) {

        const key =
            String(stallId);

        if (menuCache[key]) {
            return Promise.resolve(
                menuCache[key]
            );
        }

        return api.get({
            action: 'cargoquery',
            format: 'json',
            tables: 'StallMenuItems',
fields:
    'menu_item_id=menu_item_id,' +
    'stall_id=stall_id,' +
    'name=name,' +
    'status=status',
            where:
                'stall_id=' +
                Number(stallId) +
                " AND status='active'",
            order_by:
    'menu_item_id',
            limit: 100
        }).then(function (res) {

            const rows =
                cargoRows(res);

            menuCache[key] =
                rows;

            return rows;
        });
    }

    function optionBelongsToMenu(
        option,
        menu
    ) {

        const name =
            String(menu.name || '');

        const id =
            String(
                menu.menu_item_id || ''
            );

        const value =
            String(option.value || '');

        const text =
            String(
                option.textContent || ''
            );

        /*
         * 商品名が一意
         */
        if (
            value === name ||
            text === name
        ) {
            return true;
        }

        /*
         * Page Formsによる
         * 同名商品の識別表示
         *
         * たこ焼き (1)
         * たこ焼き (3)
         */
        const mapped =
            name + ' (' + id + ')';

        return (
            value === mapped ||
            text === mapped
        );
    }

    function makeOptions(menus) {

        const options = [];

        /*
         * 空欄
         */
        const blank =
            masterOptions.find(
                function (option) {
                    return (
                        option.value === ''
                    );
                }
            );

        if (blank) {
            options.push(
                blank.cloneNode(true)
            );
        } else {
            options.push(
                new Option('', '')
            );
        }

        menus.forEach(
            function (menu) {

                const option =
                    masterOptions.find(
                        function (candidate) {
                            return optionBelongsToMenu(
                                candidate,
                                menu
                            );
                        }
                    );

                if (option) {
                    options.push(
                        option.cloneNode(true)
                    );
                } else {
                    console.warn(
                        'Page Formsのoptionを特定できません:',
                        menu
                    );
                }
            }
        );

        return options;
    }

    function optionSignature(select) {

        return [...select.options]
            .map(function (option) {
                return (
                    option.value +
                    '::' +
                    option.textContent
                );
            })
            .join('||');
    }

    function filterMenuSelects(
        menus,
        clearSelection
    ) {

        const desiredTemplate =
            makeOptions(menus);

        const desiredSignature =
            desiredTemplate
                .map(function (option) {
                    return (
                        option.value +
                        '::' +
                        option.textContent
                    );
                })
                .join('||');

        applying = true;

        getRealMenuSelects().forEach(
            function (select) {

                const previousValue =
                    select.value;

                /*
                 * すでに正しい候補なら
                 * DOMを触らない
                 */
                if (
                    optionSignature(select) ===
                    desiredSignature
                ) {
                    if (clearSelection &&
                        select.value !== '') {

                        select.value = '';

                        if (window.jQuery) {
                            jQuery(select)
                                .trigger('change');
                        }
                    }

                    return;
                }

                const newOptions =
                    desiredTemplate.map(
                        function (option) {
                            return option
                                .cloneNode(true);
                        }
                    );

                select.replaceChildren(
                    ...newOptions
                );

                if (!clearSelection) {

                    const exists =
                        [...select.options]
                            .some(
                                function (option) {
                                    return (
                                        option.value ===
                                        previousValue
                                    );
                                }
                            );

                    if (exists) {
                        select.value =
                            previousValue;
                    }
                }

                if (clearSelection) {
                    select.value = '';
                }

                if (window.jQuery) {
                    jQuery(select)
                        .trigger('change');
                }
            }
        );

        /*
         * MutationObserverに
         * 自分自身の変更を拾わせない
         */
        setTimeout(
            function () {
                applying = false;
            },
            0
        );
    }

    function refreshMenus(
        clearSelection
    ) {

        const stall =
            document.querySelector(
                STALL_SELECTOR
            );

if (!stall) {
    return;
}

if (!stall.value) {
    /*
     * 屋台が未選択なら、
     * 進行中の古い非同期処理を無効化し、
     * 商品候補を空欄だけに戻す。
     */
    ++requestSerial;

    filterMenuSelects(
        [],
        true
    );

    return;
}

        const serial =
            ++requestSerial;

        const stallName =
            stall.value;

        resolveStallId(
            stallName
        )
        .then(function (stallId) {

            if (
                serial !==
                requestSerial
            ) {
                return null;
            }

            console.log(
                '[屋台→商品V2]',
                stallName,
                '→ stall_id=' +
                stallId
            );

            return loadMenus(
                stallId
            );

        })
        .then(function (menus) {

            if (
                !menus ||
                serial !==
                    requestSerial
            ) {
                return;
            }

            console.log(
                '[販売商品候補V2]',
                menus
            );

            filterMenuSelects(
                menus,
                clearSelection
            );

        })
        .catch(function (err) {

            console.error(
                '[屋台→商品V2] エラー:',
                err
            );
        });
    }

    /*
     * Page Formsによる
     * option再生成を検出
     */
    function mutationTouchesMenus(
        mutation
    ) {

        const target =
            mutation.target;

        if (
            target.nodeType === 1 &&
            target.matches &&
            target.matches(MENU_SELECTOR)
        ) {
            return true;
        }

        for (
            const node of
            mutation.addedNodes
        ) {

            if (
                node.nodeType !== 1
            ) {
                continue;
            }

            if (
                node.matches &&
                node.matches(MENU_SELECTOR)
            ) {
                return true;
            }

            if (
                node.querySelector &&
                node.querySelector(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }

            /*
             * SELECTの中にOPTIONが追加された
             */
            if (
                node.tagName === 'OPTION' &&
                node.parentElement &&
                node.parentElement.matches &&
                node.parentElement.matches(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }
        }

        return false;
    }

    const observer =
        new MutationObserver(
            function (mutations) {

                if (applying) {
                    return;
                }

                const touched =
                    mutations.some(
                        mutationTouchesMenus
                    );

                if (!touched) {
                    return;
                }

                clearTimeout(
                    observerTimer
                );

                /*
                 * Page Formsの再初期化が
                 * 完了してから実行
                 */
                observerTimer =
                    setTimeout(
                        function () {
                            refreshMenus(false);
                        },
                        250
                    );
            }
        );

    const form =
        document.getElementById(
            'pfForm'
        ) || document.body;

    observer.observe(
        form,
        {
            childList: true,
            subtree: true
        }
    );

    /*
     * 屋台変更
     */
    const stall =
        document.querySelector(
            STALL_SELECTOR
        );

    function onStallChange() {
        refreshMenus(true);
    }

        stall.addEventListener(
        'change',
        onStallChange
    );

    /*
     * 初期表示
     */
    refreshMenus(false);

        console.log(
        '屋台→販売商品連動を初期化しました。'
    );

});

/*
 * FestivalStallMenuOffering
 * 同一Placement内の商品重複警告
 *
 * 保存は禁止しない。
 */
(function () {
    'use strict';

    const MENU_SELECTOR =
        'select[name^="FestivalStallMenuOffering["]' +
        '[name$="[menu_item_id]"]';

    function setupOfferingDuplicateWarning() {
        const form =
            document.getElementById('pfForm');

        if (!form) {
            return;
        }

        /*
         * FestivalStallPlacementフォームだけを対象にする。
         */
        if (
            !form.querySelector(
                '[name="FestivalStallPlacement[stall_id]"]'
            )
        ) {
            return;
        }

        function getMenuSelects() {
            return [
                ...form.querySelectorAll(
                    MENU_SELECTOR
                )
            ].filter(function (select) {
                return !select.name.includes('[num]');
            });
        }

        function getWarning() {
            let warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );

            if (warning) {
                return warning;
            }

            const firstSelect =
                getMenuSelects()[0];

            if (!firstSelect) {
                return null;
            }

            warning =
                document.createElement('div');

            warning.className =
                'stall-offering-duplicate-warning';

            warning.setAttribute(
                'role',
                'status'
            );

            warning.hidden = true;

            warning.style.marginTop = '8px';
            warning.style.padding = '10px';
            warning.style.border =
                '1px solid #a2a9b1';
            warning.style.borderRadius = '4px';

/*
 * 警告は個別の商品行ではなく、
 * 販売商品multiple全体の上部に表示する。
 */
const wrapper =
    firstSelect.closest(
        '.multipleTemplateWrapper'
    );

const list =
    wrapper
        ? wrapper.querySelector(
            '.multipleTemplateList'
        )
        : null;

if (list) {
    list.insertAdjacentElement(
        'beforebegin',
        warning
    );
} else {
    const container =
        firstSelect.closest('fieldset') ||
        firstSelect.closest('td') ||
        firstSelect.parentNode;

    container.insertBefore(
        warning,
        container.firstChild
    );
}

            return warning;
        }

        function clearWarning() {
            const warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );

            if (!warning) {
                return;
            }

            warning.hidden = true;
            warning.textContent = '';
        }

        function checkDuplicates() {
            const selects =
                getMenuSelects();

            const counts = {};

            selects.forEach(function (select) {
                const value =
                    String(
                        select.value || ''
                    ).trim();

                if (!value) {
                    return;
                }

                counts[value] =
                    (counts[value] || 0) + 1;
            });

            const duplicates =
                Object.keys(counts).filter(
                    function (value) {
                        return counts[value] > 1;
                    }
                );

            if (duplicates.length === 0) {
                clearWarning();
                return;
            }

            const warning =
                getWarning();

            if (!warning) {
                return;
            }

            warning.textContent = '';

            const title =
                document.createElement('strong');

            title.textContent =
                '同じ販売商品が複数回選択されています。';

            warning.appendChild(title);

            const detail =
                document.createElement('div');

            detail.textContent =
                duplicates.join('、') +
                ' が重複しています。' +
                '重複登録でないか確認してください。' +
                '保存自体は禁止しません。';

            warning.appendChild(detail);

            warning.hidden = false;
        }

        /*
         * multipleで後から追加された行にも対応。
         */
        if (
            form.dataset
                .offeringDuplicateWarning !== '1'
        ) {
            form.dataset
                .offeringDuplicateWarning = '1';

/*
 * Page Forms / Select2 は
 * jQueryのchangeを使う場合があるため、
 * jQuery側でイベント委譲する。
 */
if (window.jQuery) {
    jQuery(form).on(
        'change.offeringDuplicateWarning',
        MENU_SELECTOR,
        function () {
            window.setTimeout(
                checkDuplicates,
                0
            );
        }
    );
} else {
    /*
     * jQueryが無い場合のフォールバック。
     */
    form.addEventListener(
        'change',
        function (event) {
            if (
                event.target.matches &&
                event.target.matches(
                    MENU_SELECTOR
                )
            ) {
                window.setTimeout(
                    checkDuplicates,
                    0
                );
            }
        }
    );
}

            const observer =
                new MutationObserver(
                    function () {
                        window.setTimeout(
                            checkDuplicates,
                            0
                        );
                    }
                );

            observer.observe(
                form,
                {
                    childList: true,
                    subtree: true
                }
            );
        }

        checkDuplicates();
    }

    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingDuplicateWarning
        );
    } else {
        setupOfferingDuplicateWarning();
    }

    mw.hook(
        'wikipage.content'
    ).add(
        setupOfferingDuplicateWarning
    );

})();

/*
 * StallMenuItem
 * 入力検証・状態日本語化
 */
(function () {
    'use strict';

    function setupStallMenuItemValidation() {
        const form = document.getElementById('pfForm');

        if (!form) {
            return;
        }

        /*
         * StallMenuItemフォーム以外では何もしない。
         */
        const nameInput = form.querySelector(
            'input[name="StallMenuItem[name]"]'
        );

        if (!nameInput) {
            return;
        }

        /*
         * 二重初期化防止
         */
        if (
            form.dataset.stallMenuItemValidationInitialized === '1'
        ) {
            return;
        }

        form.dataset.stallMenuItemValidationInitialized = '1';

        /*
         * =====================================
         * 状態を日本語表示
         * =====================================
         */
        const statusLabels = {
            active: '取扱中',
            inactive: '一時停止',
            discontinued: '取扱終了',
            unknown: '未確認'
        };

        const statusSelect = form.querySelector(
            'select[name="StallMenuItem[status]"]'
        );

        if (statusSelect) {
            Array.from(statusSelect.options).forEach(
                function (option) {
                    if (statusLabels[option.value]) {
                        option.textContent =
                            statusLabels[option.value];
                    }
                }
            );
        }


        console.log(
            '商品マスター入力チェックを初期化しました。'
        );
    }

    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemValidation
        );
    } else {
        setupStallMenuItemValidation();
    }

    mw.hook('wikipage.content').add(
        setupStallMenuItemValidation
    );

})();

/*
 * StallMenuItem
 * 同一屋台 + 同一商品名の重複警告
 *
 * 保存は禁止しない。
 */
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';

    const api = new mw.Api();

    function cargoQuote(value) {
        return "'" + String(value)
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }

    function cargoRows(response) {
        return (response.cargoquery || []).map(
            function (row) {
                return row.title || row;
            }
        );
    }

    function cargoQuery(
        tables,
        fields,
        where,
        limit
    ) {
        return api.get({
            action: 'cargoquery',
            format: 'json',
            tables: tables,
            fields: fields,
            where: where,
            limit: String(limit || 20)
        }).then(
            function (response) {
                return cargoRows(response);
            }
        );
    }

    function normalizePageName(value) {
        return String(value || '')
            .replace(/_/g, ' ')
            .trim();
    }

    function setupStallMenuItemDuplicateWarning() {
        const form =
            document.getElementById('pfForm');

        if (!form) {
            return;
        }

        /*
         * StallMenuItemフォームだけを対象にする。
         */
        const stallSelect = form.querySelector(
            'select[name="StallMenuItem[stall_id]"]'
        );

        const nameInput = form.querySelector(
            'input[name="StallMenuItem[name]"]'
        );

        if (!stallSelect || !nameInput) {
            return;
        }

        /*
         * 二重初期化防止
         */
        if (
            form.dataset
                .stallMenuItemDuplicateWarning ===
            '1'
        ) {
            return;
        }

        form.dataset
            .stallMenuItemDuplicateWarning =
            '1';

        /*
         * 警告表示欄
         */
        const warning =
            document.createElement('div');

        warning.className =
            'stall-menu-item-duplicate-warning';

        warning.setAttribute(
            'role',
            'status'
        );

        warning.hidden = true;

        warning.style.marginTop = '8px';
        warning.style.padding = '10px';
        warning.style.border = '1px solid #a2a9b1';
        warning.style.borderRadius = '4px';

        const container =
            nameInput.closest('td') ||
            nameInput.parentNode;

        container.appendChild(warning);

        let timer = null;
        let requestSerial = 0;

        /*
         * Page Formsのmappingでは
         * SELECT.valueが屋台名になる場合があるため、
         * Cargoからstall_idを解決する。
         */
        function resolveStallId() {
            const rawValue =
                String(
                    stallSelect.value || ''
                ).trim();

            if (!rawValue) {
                return Promise.resolve('');
            }

            /*
             * 数値ならそのまま使用。
             */
            if (/^\d+$/.test(rawValue)) {
                return Promise.resolve(
                    rawValue
                );
            }

            const selectedOption =
                stallSelect.options[
                    stallSelect.selectedIndex
                ];

            const selectedText =
                selectedOption
                    ? selectedOption.textContent.trim()
                    : '';

            const names = [];

            if (rawValue) {
                names.push(rawValue);
            }

            if (
                selectedText &&
                names.indexOf(selectedText) === -1
            ) {
                names.push(selectedText);
            }

            if (names.length === 0) {
                return Promise.resolve('');
            }

            const where = names.map(
                function (name) {
                    return (
                        'name=' +
                        cargoQuote(name)
                    );
                }
            ).join(' OR ');

            return cargoQuery(
                'Stalls',
                'stall_id=stall_id,' +
                    'name=stall_name',
                where,
                10
            ).then(
                function (rows) {
                    if (!rows.length) {
                        return '';
                    }

                    return String(
                        rows[0].stall_id || ''
                    );
                }
            );
        }

        function clearWarning() {
            warning.hidden = true;
            warning.textContent = '';
        }

        function showWarning(rows) {
            warning.textContent = '';

            const title =
                document.createElement('strong');

            title.textContent =
                '同じ屋台に同名の商品がすでに登録されています。';

            warning.appendChild(title);

            const text =
                document.createElement('div');

            text.textContent =
                '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';

            warning.appendChild(text);

            const list =
                document.createElement('ul');

            rows.forEach(
                function (row) {
                    const item =
                        document.createElement('li');

                    const link =
                        document.createElement('a');

                    link.href =
                        mw.util.getUrl(
                            row.page_name
                        );

                    link.textContent =
                        (
                            row.menu_name ||
                            '商品'
                        ) +
                        '(商品ID: ' +
                        row.menu_item_id +
                        ')';

                    link.target = '_blank';

                    item.appendChild(link);
                    list.appendChild(item);
                }
            );

            warning.appendChild(list);
            warning.hidden = false;
        }

        function checkDuplicate() {
            const menuName =
                nameInput.value.trim();

            if (
                !stallSelect.value ||
                !menuName
            ) {
                clearWarning();
                return;
            }

            const currentRequest =
                ++requestSerial;

            resolveStallId().then(
                function (stallId) {
                    if (
                        currentRequest !==
                        requestSerial
                    ) {
                        return null;
                    }

                    if (!stallId) {
                        clearWarning();
                        return null;
                    }

                    return cargoQuery(
                        'StallMenuItems',
                        'menu_item_id=menu_item_id,' +
                            'name=menu_name,' +
                            '_pageName=page_name',
                        'stall_id=' +
                            stallId +
                            ' AND name=' +
                            cargoQuote(
                                menuName
                            ),
                        20
                    );
                }
            ).then(
                function (rows) {
                    if (
                        rows === null ||
                        rows === undefined
                    ) {
                        return;
                    }

                    if (
                        currentRequest !==
                        requestSerial
                    ) {
                        return;
                    }

                    /*
                     * 編集画面では
                     * 自分自身を重複候補から除外。
                     */
                    const currentPage =
                        normalizePageName(
                            mw.config.get(
                                'wgPageName'
                            )
                        );

                    const duplicates =
                        rows.filter(
                            function (row) {
                                return (
                                    normalizePageName(
                                        row.page_name
                                    ) !==
                                    currentPage
                                );
                            }
                        );

                    if (
                        duplicates.length === 0
                    ) {
                        clearWarning();
                        return;
                    }

                    showWarning(
                        duplicates
                    );
                }
            ).catch(
                function (error) {
                    console.error(
                        '商品重複確認に失敗しました。',
                        error
                    );

                    clearWarning();
                }
            );
        }

        function scheduleCheck() {
            window.clearTimeout(timer);

            timer =
                window.setTimeout(
                    checkDuplicate,
                    300
                );
        }

        stallSelect.addEventListener(
            'change',
            scheduleCheck
        );

        nameInput.addEventListener(
            'input',
            scheduleCheck
        );

        nameInput.addEventListener(
            'change',
            scheduleCheck
        );

        /*
         * 編集画面で既存値が入っている場合にも確認。
         */
        scheduleCheck();

        console.log(
            '商品重複警告を初期化しました。'
        );
    }

    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemDuplicateWarning
        );
    } else {
        setupStallMenuItemDuplicateWarning();
    }

    mw.hook(
        'wikipage.content'
    ).add(
        setupStallMenuItemDuplicateWarning
    );

});

/* =========================================
 * Venue:緯度・経度バリデーション
 * ========================================= */
$(function () {
    const latitudeInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );

    const longitudeInput = document.querySelector(
        'input[name="Venue[longitude]"]'
    );

    function setupVenueCoordinateValidation(
        input,
        label,
        min,
        max
    ) {
        if (!input) {
            return;
        }

        input.inputMode = 'decimal';

        const validateCoordinate = function () {
            const value = input.value.trim();

            input.setCustomValidity('');

            /*
             * Venueでは緯度・経度自体は任意。
             * ただし片方だけの入力は禁止する。
             */
            if (value === '') {
                const otherInput =
                    input === latitudeInput
                        ? longitudeInput
                        : latitudeInput;

                if (
                    otherInput &&
                    otherInput.value.trim() !== ''
                ) {
                    input.setCustomValidity(
                        '緯度と経度は両方入力するか、両方空欄にしてください。'
                    );
                }

                return;
            }

            /*
             * 数値形式チェック
             */
            if (!/^-?\d+(\.\d+)?$/.test(value)) {
                input.setCustomValidity(
                    label + 'は数値で入力してください。'
                );
                return;
            }

            /*
             * 日本付近の範囲チェック
             */
            const number = Number(value);

            if (number < min || number > max) {
                input.setCustomValidity(
                    label +
                    'は' +
                    min +
                    '〜' +
                    max +
                    'の範囲で入力してください。'
                );
            }
        };

        input.addEventListener(
            'input',
            validateCoordinate
        );

        input.addEventListener(
            'change',
            validateCoordinate
        );

        input.addEventListener(
            'invalid',
            validateCoordinate
        );

        validateCoordinate();

        return validateCoordinate;
    }

    const validateVenueLatitude =
        setupVenueCoordinateValidation(
            latitudeInput,
            '緯度',
            20,
            46
        );

    const validateVenueLongitude =
        setupVenueCoordinateValidation(
            longitudeInput,
            '経度',
            122,
            154
        );

    /*
     * 一方の座標を変更した場合、
     * 反対側のペア整合性も再検証する。
     */
    if (
        latitudeInput &&
        validateVenueLongitude
    ) {
        latitudeInput.addEventListener(
            'input',
            validateVenueLongitude
        );

        latitudeInput.addEventListener(
            'change',
            validateVenueLongitude
        );
    }

    if (
        longitudeInput &&
        validateVenueLatitude
    ) {
        longitudeInput.addEventListener(
            'input',
            validateVenueLatitude
        );

        longitudeInput.addEventListener(
            'change',
            validateVenueLatitude
        );
    }
});

/* =========================================
 * Venue:地図ピン → 緯度・経度
 * ========================================= */
$(function () {
    const latInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );

    const lonInput = document.querySelector(
        'input[name="Venue[longitude]"]'
    );

    if (!latInput || !lonInput) {
        return;
    }

    mw.loader.using('ext.pageforms.leaflet').then(function () {
    	        const venueMarkerImagePath =
            mw.config.get('wgExtensionAssetsPath') +
            '/PageForms/libs/foreign/leaflet/images/';

        const venueMarkerIcon = L.icon({
            iconUrl:
                venueMarkerImagePath +
                'marker-icon.png',
            iconRetinaUrl:
                venueMarkerImagePath +
                'marker-icon-2x.png',
            shadowUrl:
                venueMarkerImagePath +
                'marker-shadow.png',
            iconSize: [25, 41],
            iconAnchor: [12, 41],
            popupAnchor: [1, -34],
            shadowSize: [41, 41]
        });
        if (
            document.getElementById(
                'matsuri-venue-location-map'
            )
        ) {
            return;
        }

        const mapDiv = document.createElement('div');
        mapDiv.id = 'matsuri-venue-location-map';
        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';

        const status = document.createElement('div');
        status.id = 'matsuri-venue-location-status';
        status.setAttribute(
            'aria-live',
            'polite'
        );
        status.style.marginBottom = '8px';
        status.style.fontWeight = '600';

        const help = document.createElement('div');
        help.textContent =
            '地図をクリックして会場位置を指定してください。ピンはドラッグして微調整できます。';
        help.style.marginBottom = '8px';

        const controls = document.createElement('div');
        controls.style.marginBottom = '8px';

        const clearButton =
            document.createElement('button');

        clearButton.type = 'button';
        clearButton.id =
            'matsuri-venue-location-clear';

        clearButton.textContent =
            '位置情報をクリア';

        controls.appendChild(
            clearButton
        );

        const wrapper = document.createElement('div');
        wrapper.appendChild(status);
        wrapper.appendChild(help);
        wrapper.appendChild(controls);
        wrapper.appendChild(mapDiv);

        const latRow = latInput.closest('tr');

        if (!latRow || !latRow.parentNode) {
            return;
        }

        const mapRow = document.createElement('tr');

        const th = document.createElement('th');
        th.textContent = '会場位置を地図から選択';

        const td = document.createElement('td');
        td.appendChild(wrapper);

        mapRow.appendChild(th);
        mapRow.appendChild(td);

        latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );

        function updateLocationUi() {
            const latText =
                latInput.value.trim();

            const lonText =
                lonInput.value.trim();

            if (
                latText === '' &&
                lonText === ''
            ) {
                status.textContent =
                    '位置情報:未登録';

                clearButton.disabled = true;

                return;
            }

            clearButton.disabled = false;

            if (
                latText !== '' &&
                lonText !== ''
            ) {
                status.textContent =
                    '位置情報:座標あり';

                return;
            }

            status.textContent =
                '位置情報:入力不完全';
        }

        clearButton.addEventListener(
            'click',
            function () {
                if (
                    latInput.value.trim() === '' &&
                    lonInput.value.trim() === ''
                ) {
                    updateLocationUi();
                    return;
                }

                if (
                    !window.confirm(
                        '緯度・経度をクリアします。よろしいですか?'
                    )
                ) {
                    return;
                }

                latInput.value = '';
                lonInput.value = '';

                latInput.dispatchEvent(
                    new Event(
                        'input',
                        { bubbles: true }
                    )
                );

                lonInput.dispatchEvent(
                    new Event(
                        'input',
                        { bubbles: true }
                    )
                );

                latInput.dispatchEvent(
                    new Event(
                        'change',
                        { bubbles: true }
                    )
                );

                lonInput.dispatchEvent(
                    new Event(
                        'change',
                        { bubbles: true }
                    )
                );

                updateLocationUi();
            }
        );

        latInput.addEventListener(
            'input',
            updateLocationUi
        );

        lonInput.addEventListener(
            'input',
            updateLocationUi
        );

        latInput.addEventListener(
            'change',
            updateLocationUi
        );

        lonInput.addEventListener(
            'change',
            updateLocationUi
        );

        updateLocationUi();

        const hasCoordinates =
            latInput.value.trim() !== '' &&
            lonInput.value.trim() !== '' &&
            !Number.isNaN(Number(latInput.value)) &&
            !Number.isNaN(Number(lonInput.value));

        /*
         * 既存座標があればそこを表示。
         * 新規・座標未登録なら日本全体を表示。
         */
        const initialLat = hasCoordinates
            ? Number(latInput.value)
            : 36.2048;

        const initialLon = hasCoordinates
            ? Number(lonInput.value)
            : 138.2529;

        const map = L.map(mapDiv).setView(
            [initialLat, initialLon],
            hasCoordinates ? 17 : 5
        );

        L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);

        let marker = null;

        function updateInputs(lat, lon) {
            const latValue =
                Number(lat).toFixed(6);

            const lonValue =
                Number(lon).toFixed(6);

            latInput.value = latValue;
            lonInput.value = lonValue;

            latInput.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            lonInput.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            latInput.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );

            lonInput.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );
        }

        function placeMarker(latlng) {
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true,
                        icon: venueMarkerIcon
                    }
                ).addTo(map);

                marker.on(
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();

                        updateInputs(
                            position.lat,
                            position.lng
                        );
                    }
                );
            }

            updateInputs(
                latlng.lat,
                latlng.lng
            );
        }

        if (hasCoordinates) {
            placeMarker({
                lat: initialLat,
                lng: initialLon
            });
        }

        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
        );

        /*
         * 緯度・経度を手動修正した場合も
         * ピンを同期する。
         */
        function syncMarkerFromInputs() {
            const lat =
                Number(latInput.value);

            const lon =
                Number(lonInput.value);

            const latText =
                latInput.value.trim();

            const lonText =
                lonInput.value.trim();

            /*
             * 両方空欄になった場合は
             * 地図上のピンも削除する。
             */
            if (
                latText === '' &&
                lonText === ''
            ) {
                if (marker) {
                    map.removeLayer(marker);
                    marker = null;
                }

                map.setView(
                    [36.2048, 138.2529],
                    5
                );

                return;
            }

            /*
             * 片方のみ入力、または数値不正の場合は
             * 地図上のピンを勝手に変更しない。
             */
            if (
                latText === '' ||
                lonText === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return;
            }

            const latlng = {
                lat: lat,
                lng: lon
            };

            if (marker) {
                marker.setLatLng(latlng);
            } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true,
                        icon: venueMarkerIcon
                    }
                ).addTo(map);

                marker.on(
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();

                        updateInputs(
                            position.lat,
                            position.lng
                        );
                    }
                );
            }

            map.setView(
                [lat, lon],
                17
            );
        }

        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        setTimeout(function () {
            map.invalidateSize();
        }, 100);

        console.log(
            'Venue地図ピン入力を初期化しました。'
        );
    });
});

/* =========================================
 * FestivalStallPlacement:
 * 祭り → 会場候補連動
 * ========================================= */
$(function () {
    function setupFestivalVenueFilter() {
        const festivalSelect =
            document.querySelector(
                'input[type="hidden"][name="FestivalStallPlacement[festival_id]"]'
            ) ||
            document.querySelector(
                'select[name="FestivalStallPlacement[festival_id]"]:not(.pfComboBox)'
            );

        const venueSelect =
            document.querySelector(
                'select[name="FestivalStallPlacement[venue_id]"]'
            );

        if (!festivalSelect || !venueSelect) {
            return;
        }

        if (
            venueSelect.dataset.r5FestivalVenueFilter ===
            '1'
        ) {
            return;
        }

        venueSelect.dataset.r5FestivalVenueFilter =
            '1';

        const api = new mw.Api();

        const originalOptions =
            Array.from(
                venueSelect.options
            ).map(function (option) {
                return option.cloneNode(true);
            });

        const initialFestival =
            festivalSelect.value.trim();

        const initialVenue =
            venueSelect.value.trim();

        let requestId = 0;

        function escapeCargoValue(value) {
            return String(value).replace(
                /'/g,
                "''"
            );
        }

        function getBlankOption(label) {
            let blank =
                originalOptions.find(function (option) {
                    return option.value === '';
                });

            if (blank) {
                blank=blank.cloneNode(true);
            } else {
                blank=document.createElement(
                    'option'
                );
                blank.value='';
            }

            blank.textContent=label;

            return blank;
        }

        function findOriginalOption(value) {
            const option =
                originalOptions.find(
                    function (item) {
                        return item.value === value;
                    }
                );

            if (option) {
                return option.cloneNode(true);
            }

            const dynamicOption =
                document.createElement(
                    'option'
                );

            dynamicOption.value =
                value;

            dynamicOption.textContent =
                value;

            dynamicOption.setAttribute(
                'data-r14-dynamic-venue-option',
                '1'
            );

            return dynamicOption;
        }

        function dispatchVenueChange(
            preservePlacementCoordinates
        ) {
            venueSelect.dispatchEvent(
                new CustomEvent(
                    'change',
                    {
                        bubbles: true,
                        detail: {
                            matsuriPreservePlacementCoordinates:
                                preservePlacementCoordinates === true
                        }
                    }
                )
            );
        }

        function replaceOptions(
            venuePages,
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const oldValue =
                preserveCurrent
                    ? initialVenue
                    : '';

            const fragment =
                document.createDocumentFragment();

            fragment.appendChild(
                getBlankOption('未指定')
            );

            venuePages.forEach(function (page) {
                let option =
                    findOriginalOption(page);

                if (!option) {
                    console.warn(
                        'Page Formsの元候補に会場がありません。',
                        page
                    );

                    return;
                }

                option.selected=false;
                fragment.appendChild(option);
            });

            if (
                preserveCurrent &&
                oldValue !== '' &&
                !venuePages.includes(oldValue)
            ) {
                const currentOption =
                    findOriginalOption(oldValue);

                if (currentOption) {
                    currentOption.textContent +=
                        '(現在登録値)';

                    fragment.appendChild(
                        currentOption
                    );
                }
            }

            venueSelect.replaceChildren(
                fragment
            );

            let nextValue='';

            if (
                preserveCurrent &&
                oldValue !== '' &&
                Array.from(
                    venueSelect.options
                ).some(function (option) {
                    return option.value ===
                        oldValue;
                })
            ) {
                nextValue=oldValue;
            }

            venueSelect.value=nextValue;
            venueSelect.disabled=false;

            dispatchVenueChange(
                preservePlacementCoordinates
            );
        }

        function showLoading() {
            venueSelect.replaceChildren(
                getBlankOption(
                    '会場候補を読み込み中…'
                )
            );

            venueSelect.disabled=true;
        }

        function showFailure(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const fragment =
                document.createDocumentFragment();

            fragment.appendChild(
                getBlankOption(
                    '未指定(候補取得失敗)'
                )
            );

            if (
                preserveCurrent &&
                initialVenue !== ''
            ) {
                const current =
                    findOriginalOption(
                        initialVenue
                    );

                if (current) {
                    current.textContent +=
                        '(現在登録値)';

                    current.selected=true;

                    fragment.appendChild(
                        current
                    );
                }
            }

            venueSelect.replaceChildren(
                fragment
            );

            venueSelect.disabled=false;

            dispatchVenueChange(
                preservePlacementCoordinates
            );
        }

        function loadVenues(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const festivalValue =
                festivalSelect.value.trim();

            const currentRequest =
                ++requestId;

            if (festivalValue === '') {
                venueSelect.replaceChildren(
                    getBlankOption('未指定')
                );

                venueSelect.disabled=false;

                dispatchVenueChange(
                    preservePlacementCoordinates
                );

                return;
            }

            showLoading();

            const escaped =
                escapeCargoValue(
                    festivalValue
                );

            api.get({
                action:'cargoquery',
                format:'json',
                tables:
                    'Festivals=F,' +
                    'FestivalVenues=FV,' +
                    'Venues=V',
                fields:
                    'V._pageName=venue_page,' +
                    'V.name=venue_name,' +
                    'FV.sort_order=sort_order',
                join_on:
                    'F.festival_id=FV.festival_id,' +
                    'FV.venue_id=V.venue_id',
                where:
                    "(" +
                    "F.name='" +
                    escaped +
                    "' OR " +
                    "F._pageName='" +
                    escaped +
                    "'" +
                    ")",
                order_by:
                    'FV.sort_order ASC,' +
                    'V.name ASC',
                limit:'100'
            }).then(function (data) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }

                const rows =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];

                const venuePages=[];

                rows.forEach(function (result) {
                    const row =
                        result.title ||
                        result;

                    const page =
                        row.venue_page ===
                            undefined ||
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();

                    if (
                        page !== '' &&
                        !venuePages.includes(page)
                    ) {
                        venuePages.push(page);
                    }
                });

                replaceOptions(
                    venuePages,
                    preserveCurrent,
                    preservePlacementCoordinates
                );

                console.log(
                    '祭り連動会場候補を更新しました。',
                    {
                        festival:
                            festivalValue,
                        venues:
                            venuePages
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }

                console.error(
                    '祭り連動会場候補の取得に失敗しました。',
                    error
                );

                showFailure(
                    preserveCurrent,
                    preservePlacementCoordinates
                );
            });
        }

        festivalSelect.addEventListener(
            'change',
            function () {
                loadVenues(
                    false,
                    false
                );
            }
        );

        loadVenues(
            festivalSelect.value.trim() ===
                initialFestival &&
            initialVenue !== '',
            true
        );
    }

    var festivalVenueFilterRetryTimer =
        null;

    function startFestivalVenueFilterSetup() {
        var attempts = 0;
        var maxAttempts = 50;

        if (
            !document.querySelector(
                'select[name="FestivalStallPlacement[venue_id]"]'
            )
        ) {
            return;
        }

        if (
            festivalVenueFilterRetryTimer !==
            null
        ) {
            return;
        }

        function trySetup() {
            var venueSelect;

            festivalVenueFilterRetryTimer =
                null;

            setupFestivalVenueFilter();

            venueSelect =
                document.querySelector(
                    'select[name="FestivalStallPlacement[venue_id]"]'
                );

            if (
                venueSelect &&
                venueSelect.getAttribute(
                    'data-r5-festival-venue-filter'
                ) === '1'
            ) {
                return;
            }

            attempts += 1;

            if (attempts >= maxAttempts) {
                console.warn(
                    '[R14-02] FestivalStallPlacement ' +
                    'festival/venue filter initialization timed out.'
                );
                return;
            }

            festivalVenueFilterRetryTimer =
                window.setTimeout(
                    trySetup,
                    100
                );
        }

        trySetup();
    }

    startFestivalVenueFilterSetup();

    mw.hook(
        'pf.formSetupAfter'
    ).add(
        startFestivalVenueFilterSetup
    );
});


/* =========================================
 * FestivalStallPlacement:
 * 会場連動地図ピン → 緯度・経度
 * ========================================= */
$(function () {
    const venueSelect = document.querySelector(
        'select[name="FestivalStallPlacement[venue_id]"]'
    );

    const latInput = document.querySelector(
        'input[name="FestivalStallPlacement[latitude]"]'
    );

    const lonInput = document.querySelector(
        'input[name="FestivalStallPlacement[longitude]"]'
    );

    if (
        !venueSelect ||
        !latInput ||
        !lonInput
    ) {
        return;
    }

    mw.loader.using(
        'ext.pageforms.leaflet'
    ).then(function () {
        if (
            document.getElementById(
                'matsuri-placement-location-map'
            )
        ) {
            return;
        }

        const api = new mw.Api();

        const mapDiv =
            document.createElement('div');

        mapDiv.id =
            'matsuri-placement-location-map';

        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';

        const help =
            document.createElement('div');

        help.textContent =
            '会場を選択すると会場周辺を表示します。' +
            '地図をクリックして実際の屋台位置を指定してください。' +
            'ピンはドラッグして微調整できます。';

        help.style.marginBottom = '8px';

        const wrapper =
            document.createElement('div');

        wrapper.appendChild(help);
        wrapper.appendChild(mapDiv);

        const latRow =
            latInput.closest('tr');

        if (
            !latRow ||
            !latRow.parentNode
        ) {
            return;
        }

        const mapRow =
            document.createElement('tr');

        const th =
            document.createElement('th');

        th.textContent =
            '出店位置を地図から選択';

        const td =
            document.createElement('td');

        td.appendChild(wrapper);

        mapRow.appendChild(th);
        mapRow.appendChild(td);

        latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );

        /*
         * 初期状態は日本全体。
         *
         * 既存Placementに座標がある場合は
         * 後でその位置へ移動する。
         */
        const map = L.map(
            mapDiv
        ).setView(
            [ 36.2048, 138.2529 ],
            5
        );

        L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);

        let marker = null;
        let venueRequestId = 0;

        const venueStatus =
            document.createElement(
                'div'
            );

        venueStatus.className =
            'matsuri-venue-location-status';

        venueStatus.setAttribute(
            'aria-live',
            'polite'
        );

        venueStatus.style.marginBottom =
            '8px';

        if (mapDiv.parentNode) {
            mapDiv.parentNode.insertBefore(
                venueStatus,
                mapDiv
            );
        }

        function setVenueStatus(message) {
            venueStatus.textContent =
                message;
        }

        function resetVenueView() {
            map.setView(
                [ 36.2048, 138.2529 ],
                5
            );
        }

        function dispatchInputEvents(input) {
            input.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            input.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );
        }

        function updateInputs(lat, lon) {
            latInput.value =
                Number(lat).toFixed(6);

            lonInput.value =
                Number(lon).toFixed(6);

            /*
             * 既存の必須・日本範囲チェックを
             * そのまま発火させる。
             */
            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }

        /*
         * R10-5C ISSUE-06B:
         * PageForms配下のLeaflet default PNGは
         * この環境ではHTMLへredirectされるため、
         * 外部画像に依存しないdivIconを使用。
         */
        const placementMarkerIcon =
            L.divIcon({
                className:
                    'matsuri-placement-marker-icon',

                html:
                    '<svg xmlns="http://www.w3.org/2000/svg" ' +
                    'width="28" height="42" viewBox="0 0 28 42" ' +
                    'aria-hidden="true" focusable="false">' +
                    '<path d="M14 1C6.8 1 1 6.8 1 14c0 10 13 27 13 27s13-17 13-27C27 6.8 21.2 1 14 1Z" ' +
                    'fill="#2a81cb" stroke="#ffffff" stroke-width="2"/>' +
                    '<circle cx="14" cy="14" r="5" fill="#ffffff"/>' +
                    '</svg>',

                iconSize:
                    [
                        28,
                        42
                    ],

                iconAnchor:
                    [
                        14,
                        40
                    ]
            });

        function createMarker(latlng) {
            marker = L.marker(
                latlng,
                {
                    draggable:
                        true,

                    icon:
                        placementMarkerIcon
                }
            ).addTo(map);

            marker.on(
                'dragend',
                function () {
                    const position =
                        marker.getLatLng();

                    updateInputs(
                        position.lat,
                        position.lng
                    );
                }
            );
        }

        function placeMarker(latlng) {
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                createMarker(latlng);
            }

            updateInputs(
                latlng.lat,
                latlng.lng
            );
        }

        function removeMarker() {
            if (!marker) {
                return;
            }

            map.removeLayer(marker);
            marker = null;
        }

        function clearCoordinates() {
            latInput.value = '';
            lonInput.value = '';

            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }

        function getCurrentCoordinates() {
            const lat =
                Number(latInput.value);

            const lon =
                Number(lonInput.value);

            if (
                latInput.value.trim() === '' ||
                lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return null;
            }

            return {
                lat: lat,
                lng: lon
            };
        }

        function escapeCargoValue(value) {
            return String(value)
                .replace(
                    /'/g,
                    "''"
                );
        }

        /*
         * 選択されたVenueの座標へ
         * 地図だけ移動する。
         *
         * Placementのlatitude/longitudeには
         * コピーしない。
         */
        function centerOnVenue() {
            const currentRequest =
                ++venueRequestId;

            const venuePage =
                venueSelect.value.trim();

            if (venuePage === '') {
                resetVenueView();

                setVenueStatus(
                    '会場は未指定です。' +
                    '地図上で場所を指定できます。'
                );

                return;
            }

            setVenueStatus(
                '選択した会場の位置情報を確認しています。'
            );

            api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Venues',
                fields:
                    'venue_id=venue_id,' +
                    '_pageName=page_name,' +
                    'latitude=latitude,' +
                    'longitude=longitude',
                where:
                    "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
            }).then(function (data) {
                /*
                 * 連続して会場を変更した場合、
                 * 古いレスポンスを無視する。
                 */
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }

                const result =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];

                if (result.length === 0) {
                    resetVenueView();

                    setVenueStatus(
                        '会場情報を取得できませんでした。' +
                        '地図上で場所を指定できます。'
                    );

                    console.warn(
                        '会場情報を取得できませんでした。',
                        venuePage
                    );

                    return;
                }

                const row =
                    result[0].title ||
                    result[0];

                const lat =
                    Number(row.latitude);

                const lon =
                    Number(row.longitude);

                if (
                    row.latitude === undefined ||
                    row.latitude === null ||
                    String(
                        row.latitude
                    ).trim() === '' ||
                    row.longitude === undefined ||
                    row.longitude === null ||
                    String(
                        row.longitude
                    ).trim() === '' ||
                    Number.isNaN(lat) ||
                    Number.isNaN(lon)
                ) {
                    resetVenueView();

                    setVenueStatus(
                        'この会場は位置情報未登録です。' +
                        '地図上で場所を指定できます。'
                    );

                    console.warn(
                        '選択した会場には座標が登録されていません。',
                        venuePage
                    );

                    return;
                }

                map.setView(
                    [ lat, lon ],
                    18
                );

                setVenueStatus(
                    '選択した会場の位置を表示しています。' +
                    '必要に応じて地図上で実際の位置を指定してください。'
                );

                console.log(
                    '会場位置へ地図を移動しました。',
                    {
                        venue: venuePage,
                        latitude: lat,
                        longitude: lon
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }

                resetVenueView();

                setVenueStatus(
                    '会場位置の取得に失敗しました。' +
                    '地図上で場所を指定できます。'
                );

                console.error(
                    '会場座標の取得に失敗しました。',
                    error
                );
            });
        }


        /*
         * 地図クリック
         */
        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
        );

        /*
         * 手入力された場合もピンを同期。
         */
        function syncMarkerFromInputs() {
            const coordinates =
                getCurrentCoordinates();

            if (!coordinates) {
                return;
            }

            /*
             * User-selected / manually-entered coordinates
             * take priority over a late Venue response.
             */
            venueRequestId += 1;

            setVenueStatus(
                '指定した位置を地図に表示しています。'
            );

            if (marker) {
                marker.setLatLng(
                    coordinates
                );
            } else {
                createMarker(
                    coordinates
                );
            }

            map.setView(
                [
                    coordinates.lat,
                    coordinates.lng
                ],
                18
            );
        }

        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        /*
         * 会場を変更した場合。
         *
         * 前の会場用の屋台座標を
         * 誤って残さないようクリアする。
         */
        venueSelect.addEventListener(
            'change',
            function (event) {
                const preservePlacementCoordinates =
                    !!(
                        event &&
                        event.detail &&
                        event.detail
                            .matsuriPreservePlacementCoordinates ===
                            true
                    );

                /*
                 * Even when the new Venue is blank,
                 * invalidate an older Cargo response.
                 */
                venueRequestId += 1;

                if (
                    preservePlacementCoordinates
                ) {
                    /*
                     * Existing Placement coordinates
                     * take priority over Venue center.
                     *
                     * If there are no Placement
                     * coordinates, Venue is still a
                     * useful map starting point.
                     */
                    if (
                        !getCurrentCoordinates()
                    ) {
                        centerOnVenue();
                    }

                    return;
                }

                removeMarker();
                clearCoordinates();
                centerOnVenue();
            }
        );

        /*
         * 編集時:
         * 既存Placement座標を優先。
         *
         * 新規時:
         * Venue座標へ地図を移動。
         */
        const initialCoordinates =
            getCurrentCoordinates();

        if (initialCoordinates) {
            createMarker(
                initialCoordinates
            );

            setVenueStatus(
                '登録済みの位置を地図に表示しています。'
            );

            map.setView(
                [
                    initialCoordinates.lat,
                    initialCoordinates.lng
                ],
                18
            );
        } else {
            centerOnVenue();
        }

        /*
         * R10-5C ISSUE-06A:
         * 折りたたみ中はmapが0x0なので、
         * 実際に展開された後でも
         * Leafletの内部サイズを再計算する。
         */
        const mapCollapsible =
            mapDiv.closest(
                '.mw-collapsible'
            );

        if (
            mapCollapsible
        ) {
            $(
                mapCollapsible
            ).on(
                'afterExpand.mw-collapsible',
                function () {
                    setTimeout(
                        function () {
                            map.invalidateSize();
                        },
                        0
                    );
                }
            );
        }


        /*
         * 初期状態ですでに表示されている
         * ケースの既存挙動も維持。
         */
        setTimeout(
            function () {
                map.invalidateSize();
            },
            100
        );

        console.log(
            '出店位置地図ピン入力を初期化しました。'
        );
    });
});

/* =========================================
 * Venue:公式サイトURLの形式チェック
 * ========================================= */
$(function () {
    const officialSiteInput = document.querySelector(
        'input[name="Venue[official_site]"]'
    );

    if (!officialSiteInput) {
        return;
    }

    officialSiteInput.inputMode = 'url';

    const validateVenueOfficialSite = function () {
        const value = officialSiteInput.value.trim();

        officialSiteInput.setCustomValidity('');

        /*
         * 空欄は許可。
         */
        if (value === '') {
            return;
        }

        try {
            const url = new URL(value);

            /*
             * http:// または https:// のみ許可。
             */
            if (
                url.protocol !== 'http:' &&
                url.protocol !== 'https:'
            ) {
                officialSiteInput.setCustomValidity(
                    '公式サイトURLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            officialSiteInput.setCustomValidity(
                '公式サイトURLを正しいURL形式で入力してください。'
            );
        }
    };

    officialSiteInput.addEventListener(
        'input',
        validateVenueOfficialSite
    );

    officialSiteInput.addEventListener(
        'change',
        validateVenueOfficialSite
    );

    officialSiteInput.addEventListener(
        'invalid',
        validateVenueOfficialSite
    );

    validateVenueOfficialSite();
});

/*
 * Festival
 * 公式URLの形式チェック
 *
 * 空欄は許可。
 * 入力された場合は http:// または https:// のURLのみ許可する。
 */
(function () {
    const fields = [
        'official_site',
        'official_x',
        'official_instagram',
        'official_facebook',
        'official_youtube'
    ];

    fields.forEach(function (fieldName) {
        const input = document.querySelector(
            'input[name="Festival[' + fieldName + ']"]'
        );

        if (!input) {
            return;
        }

        input.inputMode = 'url';

        const validateFestivalUrl = function () {
            const value = input.value.trim();

            input.setCustomValidity('');

            if (value === '') {
                return;
            }

            try {
                const url = new URL(value);

                if (
                    url.protocol !== 'http:' &&
                    url.protocol !== 'https:'
                ) {
                    input.setCustomValidity(
                        'URLは http:// または https:// で始まるURLを入力してください。'
                    );
                }
            } catch (e) {
                input.setCustomValidity(
                    '正しいURL形式で入力してください。'
                );
            }
        };

        input.addEventListener('input', validateFestivalUrl);
        input.addEventListener('change', validateFestivalUrl);
        input.addEventListener('invalid', validateFestivalUrl);
    });
})();

/* =========================================
 * FestivalType:slug形式チェック
 * ========================================= */
$(function () {
    const slugInput = document.querySelector(
        'input[name="FestivalType[slug]"]'
    );

    if (!slugInput) {
        return;
    }

    slugInput.spellcheck = false;

    const validateFestivalTypeSlug = function () {
        const value = slugInput.value.trim();

        slugInput.setCustomValidity('');

        /*
         * 空欄の必須チェックは
         * Page Forms の mandatory に任せる。
         */
        if (value === '') {
            return;
        }

        /*
         * 英小文字・数字を基本とし、
         * 単語の区切りに半角ハイフンのみ許可する。
         *
         * 先頭・末尾のハイフン、
         * 連続ハイフンは許可しない。
         */
        if (
            !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
        ) {
            slugInput.setCustomValidity(
                'slugは英小文字・数字・半角ハイフンで入力してください。ハイフンは単語の区切りにのみ使用できます。'
            );
        }
    };

    slugInput.addEventListener(
        'input',
        validateFestivalTypeSlug
    );

    slugInput.addEventListener(
        'change',
        validateFestivalTypeSlug
    );

    slugInput.addEventListener(
        'invalid',
        validateFestivalTypeSlug
    );

    validateFestivalTypeSlug();
});

/* =========================================
 * FestivalType:
 * 上位分類の自己参照・循環参照チェック
 *
 * Page Forms が select 要素を差し替えても
 * 動作するようイベント委譲を使用する。
 * ========================================= */
window.matsuriFestivalTypeParentValidationVersion = '20260821-v2';
(function () {
    const currentTypeId = Number(
        mw.config.get('wgArticleId')
    );

    let requestId = 0;

    function escapeCargoValue(value) {
        return String(value).replace(
            /'/g,
            "''"
        );
    }

        function getTypeByName(name) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();

            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'FestivalTypes',
                fields:
                    'type_id=type_id,' +
                    'name=name,' +
                    'parent_id=parent_id',
                where:
                    "name='" +
                    escapeCargoValue(name) +
                    "'",
                limit: '2'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];

            return rows.map(function (item) {
                return item.title || item;
            });
        });
    }

    function getTypeById(typeId) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();

            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'FestivalTypes',
                fields:
                    'type_id=type_id,' +
                    'name=name,' +
                    'parent_id=parent_id',
                where:
                    'type_id=' +
                    Number(typeId),
                limit: '1'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];

            if (rows.length === 0) {
                return null;
            }

            return rows[0].title || rows[0];
        });
    }

    function validateFestivalTypeParent(
        parentSelect
    ) {
        const thisRequest = ++requestId;

        parentSelect.setCustomValidity('');

        const parentName =
            parentSelect.value.trim();

        const nameInput = document.querySelector(
            'input[name="FestivalType[name]"]'
        );

        const currentName =
            nameInput
                ? nameInput.value.trim()
                : '';

        /*
         * 親なしは正常。
         */
        if (parentName === '') {
            return;
        }

        /*
         * 分類名が同じならAPIを待たず
         * 即座に自己参照として拒否する。
         */
        if (
            currentName !== '' &&
            parentName === currentName
        ) {
            parentSelect.setCustomValidity(
                '自分自身を上位分類に設定することはできません。'
            );
            return;
        }

        /*
         * 新規ページはまだtype_idを持たないため、
         * 既存階層との循環は発生しない。
         */
        if (
            !Number.isInteger(currentTypeId) ||
            currentTypeId <= 0
        ) {
            return;
        }

        /*
         * API確認中はフォーム送信を止める。
         */
        parentSelect.setCustomValidity(
            '上位分類を確認しています。'
        );

        /*
         * 非同期処理中に別の選択へ変更された、
         * またはPage Formsがselectを差し替えたか確認する。
         */
        function isStaleRequest() {
            return (
                thisRequest !== requestId ||
                !document.contains(parentSelect)
            );
        }

        /*
         * 選択した分類から親を順番に辿る。
         *
         * async / await は使用せず、
         * Promise の再帰処理で階層を確認する。
         */
        function walkParentChain(
            parentId,
            visited
        ) {
            if (
                parentId === undefined ||
                parentId === null ||
                String(parentId).trim() === ''
            ) {
                return Promise.resolve(true);
            }

            const numericParentId =
                Number(parentId);

            /*
             * 現在編集中の分類へ戻れば循環。
             */
            if (
                numericParentId ===
                currentTypeId
            ) {
                parentSelect.setCustomValidity(
                    'この上位分類を設定すると分類階層が循環するため選択できません。'
                );

                return Promise.resolve(false);
            }

            /*
             * 既存データ側ですでに循環している場合。
             */
            if (
                visited.has(
                    numericParentId
                )
            ) {
                parentSelect.setCustomValidity(
                    '選択した上位分類の階層に循環があります。'
                );

                return Promise.resolve(false);
            }

            visited.add(
                numericParentId
            );

            return getTypeById(
                numericParentId
            ).then(function (row) {
                if (isStaleRequest()) {
                    return false;
                }

                if (!row) {
                    parentSelect.setCustomValidity(
                        '上位分類の階層情報を確認できませんでした。'
                    );

                    return false;
                }

                return walkParentChain(
                    row.parent_id,
                    visited
                );
            });
        }

        return getTypeByName(
            parentName
        ).then(function (parentRows) {
            /*
             * その間に別の選択へ変更された場合は
             * 古い結果を無視する。
             */
            if (isStaleRequest()) {
                return false;
            }

            if (parentRows.length === 0) {
                parentSelect.setCustomValidity(
                    '選択した上位分類を確認できませんでした。'
                );

                return false;
            }

            /*
             * 同名分類が複数ある場合は
             * parent_id を一意に決められない。
             */
            if (parentRows.length > 1) {
                parentSelect.setCustomValidity(
                    '同じ名前の祭り分類が複数存在するため、上位分類を特定できません。'
                );

                return false;
            }

            const selectedParent =
                parentRows[0];

            const selectedTypeId =
                Number(
                    selectedParent.type_id
                );

            /*
             * IDでも自己参照をチェックする。
             * 分類名を編集中に変更した場合にも有効。
             */
            if (
                selectedTypeId ===
                currentTypeId
            ) {
                parentSelect.setCustomValidity(
                    '自分自身を上位分類に設定することはできません。'
                );

                return false;
            }

            const visited =
                new Set([
                    selectedTypeId
                ]);

            return walkParentChain(
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                /*
                 * すべて正常。
                 */
                parentSelect.setCustomValidity('');
            }

            return isValid;
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }

            parentSelect.setCustomValidity(
                '上位分類を確認できませんでした。'
            );

            console.error(
                '祭り分類の上位分類チェックに失敗しました。',
                error
            );

            return false;
        });
    }

    /*
     * Page Forms が select を差し替えても
     * document 側で変更を拾う。
     */
document.addEventListener(
    'change',
    function (event) {
        const target =
            event.target;

        if (
            target &&
            target.matches(
                'select[name="FestivalType[parent_id]"]'
            )
        ) {
            validateFestivalTypeParent(
                target
            );
        }
    },
    true
);

    /*
     * 初期表示時にも現在存在するselectを確認。
     */
    function validateCurrentParent() {
        const parentSelect =
            document.querySelector(
                'select[name="FestivalType[parent_id]"]'
            );

        if (parentSelect) {
            validateFestivalTypeParent(
                parentSelect
            );
        }
    }

    if (
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            validateCurrentParent
        );
    } else {
        validateCurrentParent();
    }

    /*
     * Page Formsによる描画後にも再確認する。
     */
    if (
        typeof mw !== 'undefined' &&
        mw.hook
    ) {
        mw.hook(
            'wikipage.content'
        ).add(
            validateCurrentParent
        );
    }
})();

/* =========================================
 * Area:
 * Area ID・上位地域・地域区分の整合性チェック
 *
 * 既存Areaデータの正式な階層ルール:
 *
 * prefecture
 *   parent_id = 0
 *
 * city / special_ward / town / village
 *   parent = prefecture
 *
 * ward
 *   parent = city
 *
 * 自己参照・存在しない親・循環参照も拒否する。
 *
 * MediaWiki 1.43対応のため
 * async / await は使用しない。
 * ========================================= */

window.matsuriAreaParentValidationVersion = '20260821-v1';

(function () {
    let requestId = 0;

    function getAreaById(areaId) {
        return mw.loader.using(
            'mediawiki.api'
        ).then(function () {
            const api = new mw.Api();

            return api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Areas',
                fields:
                    'area_id=area_id,' +
                    'name=name,' +
                    'area_type=area_type,' +
                    'parent_id=parent_id',
                where:
                    'area_id=' +
                    Number(areaId),
                limit: '2'
            });
        }).then(function (data) {
            const rows =
                Array.isArray(data.cargoquery)
                    ? data.cargoquery
                    : [];

            return rows.map(function (item) {
                return item.title || item;
            });
        });
    }

    function getExpectedParentType(areaType) {
        switch (areaType) {
            case 'prefecture':
                return '';

            case 'city':
            case 'special_ward':
            case 'town':
            case 'village':
                return 'prefecture';

            case 'ward':
                return 'city';

            default:
                return null;
        }
    }

    function getParentTypeMessage(areaType) {
        switch (areaType) {
            case 'city':
                return '市の上位地域には都道府県を指定してください。';

            case 'special_ward':
                return '特別区の上位地域には都道府県を指定してください。';

            case 'town':
                return '町の上位地域には都道府県を指定してください。';

            case 'village':
                return '村の上位地域には都道府県を指定してください。';

            case 'ward':
                return '行政区の上位地域には市を指定してください。';

            default:
                return '地域区分と上位地域の組み合わせが正しくありません。';
        }
    }

    function validateAreaParent() {
        const areaIdInput =
            document.querySelector(
                'input[name="Area[area_id]"]'
            );

        const areaTypeSelect =
            document.querySelector(
                'select[name="Area[area_type]"]'
            );

        const parentIdInput =
            document.querySelector(
                'input[name="Area[parent_id]"]'
            );

        /*
         * Areaフォーム以外では何もしない。
         */
        if (
            !areaIdInput ||
            !areaTypeSelect ||
            !parentIdInput
        ) {
            return;
        }

        const thisRequest = ++requestId;

        areaIdInput.setCustomValidity('');
        parentIdInput.setCustomValidity('');

        const areaIdText =
            areaIdInput.value.trim();

        const parentIdText =
            parentIdInput.value.trim();

        const areaType =
            areaTypeSelect.value;

        /*
         * Area ID は正の整数。
         */
        if (
            !/^[1-9][0-9]*$/.test(
                areaIdText
            )
        ) {
            areaIdInput.setCustomValidity(
                'Area IDは1以上の整数で入力してください。'
            );

            return;
        }

        const areaId =
            Number(areaIdText);

        /*
         * parent_id は0以上の整数。
         */
        if (
            !/^(0|[1-9][0-9]*)$/.test(
                parentIdText
            )
        ) {
            parentIdInput.setCustomValidity(
                '上位地域IDは0以上の整数で入力してください。'
            );

            return;
        }

        const parentId =
            Number(parentIdText);

        const expectedParentType =
            getExpectedParentType(
                areaType
            );

        /*
         * 想定外のarea_type。
         */
        if (
            expectedParentType === null
        ) {
            areaTypeSelect.setCustomValidity(
                '地域区分を正しく選択してください。'
            );

            return;
        }

        areaTypeSelect.setCustomValidity('');

        /*
         * 都道府県は必ずROOT。
         */
        if (
            areaType === 'prefecture'
        ) {
            if (parentId !== 0) {
                parentIdInput.setCustomValidity(
                    '都道府県の上位地域IDは0にしてください。'
                );

                return;
            }

            /*
             * 都道府県 parent_id=0 は正常。
             */
            return;
        }

        /*
         * 都道府県以外は必ず親を持つ。
         */
        if (parentId === 0) {
            parentIdInput.setCustomValidity(
                getParentTypeMessage(
                    areaType
                )
            );

            return;
        }

        /*
         * 自己参照。
         */
        if (parentId === areaId) {
            parentIdInput.setCustomValidity(
                '自分自身を上位地域に設定することはできません。'
            );

            return;
        }

        /*
         * API確認中は保存を止める。
         */
        parentIdInput.setCustomValidity(
            '上位地域を確認しています。'
        );

        function isStaleRequest() {
            return (
                thisRequest !== requestId ||
                !document.contains(
                    parentIdInput
                )
            );
        }

        /*
         * 親を順番に辿って循環参照を確認。
         */
        function walkParentChain(
            nextParentId,
            visited
        ) {
            if (
                nextParentId === undefined ||
                nextParentId === null ||
                String(nextParentId).trim() === '' ||
                Number(nextParentId) === 0
            ) {
                return Promise.resolve(true);
            }

            const numericParentId =
                Number(nextParentId);

            /*
             * 現在編集中のAreaへ戻れば循環。
             */
            if (
                numericParentId === areaId
            ) {
                parentIdInput.setCustomValidity(
                    'この上位地域を設定すると地域階層が循環するため指定できません。'
                );

                return Promise.resolve(false);
            }

            /*
             * 既存データ側ですでに循環している場合。
             */
            if (
                visited.has(
                    numericParentId
                )
            ) {
                parentIdInput.setCustomValidity(
                    '選択した上位地域の階層に循環があります。'
                );

                return Promise.resolve(false);
            }

            visited.add(
                numericParentId
            );

            return getAreaById(
                numericParentId
            ).then(function (rows) {
                if (isStaleRequest()) {
                    return false;
                }

                if (rows.length !== 1) {
                    parentIdInput.setCustomValidity(
                        '上位地域の階層情報を確認できませんでした。'
                    );

                    return false;
                }

                return walkParentChain(
                    rows[0].parent_id,
                    visited
                );
            });
        }

        return getAreaById(
            parentId
        ).then(function (parentRows) {
            if (isStaleRequest()) {
                return false;
            }

            /*
             * 存在しないparent_id。
             */
            if (parentRows.length === 0) {
                parentIdInput.setCustomValidity(
                    '指定した上位地域IDは存在しません。'
                );

                return false;
            }

            /*
             * area_id重複がDB側に存在する異常状態。
             */
            if (parentRows.length > 1) {
                parentIdInput.setCustomValidity(
                    '同じArea IDの地域が複数存在するため、上位地域を特定できません。'
                );

                return false;
            }

            const selectedParent =
                parentRows[0];

            /*
             * 地域区分と親地域区分の整合性。
             */
            if (
                selectedParent.area_type !==
                expectedParentType
            ) {
                parentIdInput.setCustomValidity(
                    getParentTypeMessage(
                        areaType
                    )
                );

                return false;
            }

            const visited =
                new Set([
                    parentId
                ]);

            return walkParentChain(
                selectedParent.parent_id,
                visited
            );
        }).then(function (isValid) {
            if (
                isValid === true &&
                !isStaleRequest()
            ) {
                parentIdInput.setCustomValidity('');
            }

            return isValid;
        }, function (error) {
            if (isStaleRequest()) {
                return false;
            }

            parentIdInput.setCustomValidity(
                '上位地域を確認できませんでした。'
            );

            console.error(
                'Areaの上位地域チェックに失敗しました。',
                error
            );

            return false;
        });
    }

    /*
     * Page Forms上で値が変更された場合。
     */
    document.addEventListener(
        'change',
        function (event) {
            const target =
                event.target;

            if (
                target &&
                (
                    target.matches(
                        'input[name="Area[area_id]"]'
                    ) ||
                    target.matches(
                        'select[name="Area[area_type]"]'
                    ) ||
                    target.matches(
                        'input[name="Area[parent_id]"]'
                    )
                )
            ) {
                validateAreaParent();
            }
        },
        true
    );

    /*
     * 初期表示時の検証。
     */
    function validateCurrentArea() {
        if (
            document.querySelector(
                'input[name="Area[area_id]"]'
            )
        ) {
            validateAreaParent();
        }
    }

    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            validateCurrentArea
        );
    } else {
        validateCurrentArea();
    }
}());

/* ========================================
 * FestivalCalendar 開催年4桁チェック
 * ======================================== */

$( function () {

    const yearInput = document.querySelector(
        'input[name="FestivalCalendar[year]"]'
    );

    if ( !yearInput ) {
        return;
    }

    /*
     * 二重初期化防止
     */
    if (
        yearInput.dataset
            .festivalCalendarYearValidation === '1'
    ) {
        return;
    }

    yearInput.dataset
        .festivalCalendarYearValidation = '1';

    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;

    const validateYear = function () {

        const value =
            yearInput.value.trim();

        /*
         * 空欄については
         * Page Forms の mandatory に任せる。
         */
        if (
            value !== '' &&
            !/^\d{4}$/.test( value )
        ) {

            yearInput.setCustomValidity(
                '開催年は4桁の数字で入力してください(例:2027)'
            );

        } else {

            yearInput.setCustomValidity( '' );

        }

    };

    yearInput.addEventListener(
        'input',
        validateYear
    );

    yearInput.addEventListener(
        'change',
        validateYear
    );

    /*
     * 編集画面を開いた時点の値も検査
     */
    validateYear();

    window
        .matsuriFestivalCalendarYearValidationVersion =
        '20260821-v1';

} );

/* ========================================
 * FestivalCalendar 開催日の前後関係チェック
 * ======================================== */

$( function () {

    const startInput = document.querySelector(
        'input[name="FestivalCalendar[start_date]"]'
    );

    const endInput = document.querySelector(
        'input[name="FestivalCalendar[end_date]"]'
    );

    if ( !startInput || !endInput ) {
        return;
    }

    if (
        endInput.dataset
            .festivalCalendarDateValidation === '1'
    ) {
        return;
    }

    endInput.dataset
        .festivalCalendarDateValidation = '1';

    const validateDates = function () {

        const startDate =
            startInput.value.trim();

        const endDate =
            endInput.value.trim();

        /*
         * 終了日は任意。
         * 両方入力されている場合だけ前後関係を確認する。
         *
         * type=date の値は YYYY-MM-DD なので
         * 文字列比較で日付順を判定できる。
         */
        if (
            startDate !== '' &&
            endDate !== '' &&
            endDate < startDate
        ) {

            endInput.setCustomValidity(
                '終了日は開始日以降の日付を入力してください。'
            );

        } else {

            endInput.setCustomValidity( '' );

        }

    };

    startInput.addEventListener(
        'input',
        validateDates
    );

    startInput.addEventListener(
        'change',
        validateDates
    );

    endInput.addEventListener(
        'input',
        validateDates
    );

    endInput.addEventListener(
        'change',
        validateDates
    );

    validateDates();

    window
        .matsuriFestivalCalendarDateValidationVersion =
        '20260821-v1';

} );

/* ========================================
 * FestivalCalendar 予想来場者数チェック
 * ======================================== */

$( function () {

    const visitorsInput = document.querySelector(
        'input[name="FestivalCalendar[expected_visitors]"]'
    );

    if ( !visitorsInput ) {
        return;
    }

    /*
     * 二重初期化防止
     */
    if (
        visitorsInput.dataset
            .festivalCalendarVisitorsValidation === '1'
    ) {
        return;
    }

    visitorsInput.dataset
        .festivalCalendarVisitorsValidation = '1';

    visitorsInput.inputMode = 'numeric';

    const validateVisitors = function () {

        const value =
            visitorsInput.value.trim();

        /*
         * 空欄は許可。
         * 入力する場合は0以上の整数のみ。
         */
        if (
            value !== '' &&
            !/^\d+$/.test( value )
        ) {

            visitorsInput.setCustomValidity(
                '予想来場者数は0以上の整数で入力してください。'
            );

        } else {

            visitorsInput.setCustomValidity( '' );

        }

    };

    visitorsInput.addEventListener(
        'input',
        validateVisitors
    );

    visitorsInput.addEventListener(
        'change',
        validateVisitors
    );

    validateVisitors();

    window
        .matsuriFestivalCalendarVisitorsValidationVersion =
        '20260821-v1';

} );

/*
 * R9-3 MatsuriWiki privacy-safe photo uploader candidate.
 *
 * Candidate mode:
 *   REAL_UPLOAD_ENABLED = false
 *
 * No production upload can occur while this flag is false.
 */
(function () {
    'use strict';

    const REAL_UPLOAD_ENABLED = true;

    const UI_ID =
        'r9-photo-privacy-uploader';

    const MAX_OUTPUT_BYTES =
        1536 * 1024;

    const MAX_EDGE =
        1600;

    const MIN_EDGE =
        720;

    const MAIN_SELECTOR =
        '[name="FestivalStallPlacement[main_image]"]';

    const POSITION_SELECTOR =
        '[name="FestivalStallPlacement[position_status]"]';

    const LAT_SELECTOR =
        '[name="FestivalStallPlacement[latitude]"]';

    const LON_SELECTOR =
        '[name="FestivalStallPlacement[longitude]"]';

    function text(codePoints) {
        return codePoints
            .map(function (n) {
                return String.fromCodePoint(n);
            })
            .join('');
    }

    const LABELS = {
        title:
            '\u5199\u771f\u3092\u5b89\u5168\u306b\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9',

        help:
            '\u5199\u771f\u306f\u30d6\u30e9\u30a6\u30b6\u5185\u3067\u753b\u50cf\u5316\u3057\u76f4\u3057\u3001GPS\u306a\u3069\u306eEXIF\u30e1\u30bf\u30c7\u30fc\u30bf\u3092\u9664\u53bb\u3057\u3066\u304b\u3089JPEG\u3068\u3057\u3066\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u3057\u307e\u3059\u3002',

        select:
            '\u5199\u771f\u3092\u9078\u629e',

        publicName:
            '\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d',

        gpsFound:
            'GPS\u4f4d\u7f6e\u5019\u88dc\u304c\u898b\u3064\u304b\u308a\u307e\u3057\u305f\u3002\u81ea\u52d5\u3067\u5ea7\u6a19\u306f\u5909\u66f4\u3057\u307e\u305b\u3093\u3002',

        gpsNotFound:
            'GPS\u4f4d\u7f6e\u5019\u88dc\u306f\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002',

        adoptGps:
            '\u3053\u306e\u4f4d\u7f6e\u5019\u88dc\u3092\u4f7f\u7528',

        upload:
            '\u5b89\u5168\u306b\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9',

        reset:
            '\u9078\u629e\u3057\u305f\u5199\u771f\u3092\u30ea\u30bb\u30c3\u30c8',

        processing:
            '\u30d6\u30e9\u30a6\u30b6\u5185\u3067\u5b89\u5168\u51e6\u7406\u4e2d...',

        ready:
            '\u30b5\u30cb\u30bf\u30a4\u30ba\u6e08\u307fJPEG\u306e\u6e96\u5099\u304c\u3067\u304d\u307e\u3057\u305f\u3002',

        dryRun:
            'R9-3 candidate\u306f\u30c9\u30e9\u30a4\u30e9\u30f3\u4e2d\u306e\u305f\u3081\u3001\u5b9f\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306f\u7121\u52b9\u3067\u3059\u3002',

        uploadSuccess:
            '\u5b89\u5168\u306aJPEG\u306e\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306b\u6210\u529f\u3057\u307e\u3057\u305f\u3002',

        uploadFailure:
            '\u30a2\u30c3\u30d7\u30ed\u30fc\u30c9\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002',

        existingFile:
            '\u540c\u3058\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d\u304c\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059\u3002\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u5909\u66f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002',

        badType:
            'JPG\u3001PNG\u3001WebP\u306e\u3044\u305a\u308c\u304b\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002',

        badName:
            '\u516c\u958b\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002',

        gpsApplied:
            '\u4f4d\u7f6e\u5019\u88dc\u3092\u7def\u5ea6\u30fb\u7d4c\u5ea6\u306b\u53cd\u6620\u3057\u307e\u3057\u305f\u3002\u4f4d\u7f6e\u78ba\u8a8d\u72b6\u614b\u306f\u5909\u66f4\u3057\u3066\u3044\u307e\u305b\u3093\u3002',

        replaceCoords:
            '\u3059\u3067\u306b\u5165\u529b\u3055\u308c\u3066\u3044\u308b\u7def\u5ea6\u30fb\u7d4c\u5ea6\u3092\u3001\u5199\u771f\u306e\u4f4d\u7f6e\u5019\u88dc\u3067\u7f6e\u304d\u63db\u3048\u307e\u3059\u304b\uff1f'
    };

    function dispatchInputChange(element) {
        element.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );

        element.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }

    function ascii(view, offset, count) {
        let out = '';

        if (
            offset < 0 ||
            count < 0 ||
            offset + count > view.byteLength
        ) {
            return '';
        }

        for (
            let i = 0;
            i < count;
            i++
        ) {
            const n =
                view.getUint8(
                    offset + i
                );

            if (n === 0) {
                break;
            }

            out +=
                String.fromCharCode(n);
        }

        return out;
    }

    function detectImageType(buffer) {
        const view =
            new DataView(buffer);

        if (
            view.byteLength >= 3 &&
            view.getUint8(0) === 0xff &&
            view.getUint8(1) === 0xd8 &&
            view.getUint8(2) === 0xff
        ) {
            return 'image/jpeg';
        }

        if (
            view.byteLength >= 8 &&
            view.getUint32(0, false) ===
                0x89504e47 &&
            view.getUint32(4, false) ===
                0x0d0a1a0a
        ) {
            return 'image/png';
        }

        if (
            view.byteLength >= 12 &&
            ascii(
                view,
                0,
                4
            ) === 'RIFF' &&
            ascii(
                view,
                8,
                4
            ) === 'WEBP'
        ) {
            return 'image/webp';
        }

        return '';
    }

    function jpegExif(buffer) {
        const view =
            new DataView(buffer);

        if (
            view.byteLength < 4 ||
            view.getUint16(
                0,
                false
            ) !== 0xffd8
        ) {
            return null;
        }

        let p = 2;

        while (
            p + 4 <=
            view.byteLength
        ) {
            if (
                view.getUint8(p) !==
                0xff
            ) {
                p++;
                continue;
            }

            const marker =
                view.getUint8(
                    p + 1
                );

            if (
                marker === 0xda ||
                marker === 0xd9
            ) {
                return null;
            }

            if (
                marker >= 0xd0 &&
                marker <= 0xd7
            ) {
                p += 2;
                continue;
            }

            if (
                p + 4 >
                view.byteLength
            ) {
                return null;
            }

            const len =
                view.getUint16(
                    p + 2,
                    false
                );

            if (
                len < 2 ||
                p + 2 + len >
                    view.byteLength
            ) {
                return null;
            }

            if (
                marker === 0xe1 &&
                ascii(
                    view,
                    p + 4,
                    6
                ) === 'Exif'
            ) {
                return {
                    view:
                        view,

                    tiff:
                        p + 10
                };
            }

            p +=
                2 + len;
        }

        return null;
    }

    function pngExif(buffer) {
        const view =
            new DataView(buffer);

        if (
            detectImageType(
                buffer
            ) !== 'image/png'
        ) {
            return null;
        }

        let p = 8;

        while (
            p + 12 <=
            view.byteLength
        ) {
            const size =
                view.getUint32(
                    p,
                    false
                );

            const type =
                ascii(
                    view,
                    p + 4,
                    4
                );

            const data =
                p + 8;

            const end =
                data + size;

            if (
                end + 4 >
                view.byteLength
            ) {
                return null;
            }

            if (
                type === 'eXIf'
            ) {
                let tiff =
                    data;

                if (
                    size >= 6 &&
                    ascii(
                        view,
                        data,
                        6
                    ) === 'Exif'
                ) {
                    tiff += 6;
                }

                return {
                    view:
                        view,

                    tiff:
                        tiff
                };
            }

            p =
                end + 4;
        }

        return null;
    }

    function webpExif(buffer) {
        const view =
            new DataView(buffer);

        if (
            detectImageType(
                buffer
            ) !== 'image/webp'
        ) {
            return null;
        }

        let p = 12;

        while (
            p + 8 <=
            view.byteLength
        ) {
            const type =
                ascii(
                    view,
                    p,
                    4
                );

            const size =
                view.getUint32(
                    p + 4,
                    true
                );

            const data =
                p + 8;

            const end =
                data + size;

            if (
                end >
                view.byteLength
            ) {
                return null;
            }

            if (
                type === 'EXIF'
            ) {
                let tiff =
                    data;

                if (
                    size >= 6 &&
                    ascii(
                        view,
                        data,
                        6
                    ) === 'Exif'
                ) {
                    tiff += 6;
                }

                return {
                    view:
                        view,

                    tiff:
                        tiff
                };
            }

            p =
                end +
                (
                    size % 2
                );
        }

        return null;
    }

    function getExifContainer(
        buffer,
        imageType
    ) {
        if (
            imageType ===
            'image/jpeg'
        ) {
            return jpegExif(
                buffer
            );
        }

        if (
            imageType ===
            'image/png'
        ) {
            return pngExif(
                buffer
            );
        }

        if (
            imageType ===
            'image/webp'
        ) {
            return webpExif(
                buffer
            );
        }

        return null;
    }

    function parseGps(
        buffer,
        imageType
    ) {
        const found =
            getExifContainer(
                buffer,
                imageType
            );

        if (!found) {
            return null;
        }

        try {
            const view =
                found.view;

            const tiff =
                found.tiff;

            if (
                tiff < 0 ||
                tiff + 8 >
                    view.byteLength
            ) {
                return null;
            }

            const byteOrder =
                view.getUint16(
                    tiff,
                    false
                );

            const littleEndian =
                byteOrder === 0x4949
                    ? true
                    : byteOrder === 0x4d4d
                        ? false
                        : null;

            if (
                littleEndian ===
                null
            ) {
                return null;
            }

            const u16 =
                function (offset) {
                    if (
                        offset < 0 ||
                        offset + 2 >
                            view.byteLength
                    ) {
                        throw new Error(
                            'EXIF_BOUNDS'
                        );
                    }

                    return view.getUint16(
                        offset,
                        littleEndian
                    );
                };

            const u32 =
                function (offset) {
                    if (
                        offset < 0 ||
                        offset + 4 >
                            view.byteLength
                    ) {
                        throw new Error(
                            'EXIF_BOUNDS'
                        );
                    }

                    return view.getUint32(
                        offset,
                        littleEndian
                    );
                };

            if (
                u16(
                    tiff + 2
                ) !== 42
            ) {
                return null;
            }

            const ifd0 =
                tiff +
                u32(
                    tiff + 4
                );

            const ifd0Count =
                u16(ifd0);

            let gpsOffset =
                null;

            for (
                let i = 0;
                i < ifd0Count;
                i++
            ) {
                const entry =
                    ifd0 +
                    2 +
                    i * 12;

                if (
                    entry + 12 >
                    view.byteLength
                ) {
                    return null;
                }

                if (
                    u16(entry) ===
                    0x8825
                ) {
                    gpsOffset =
                        u32(
                            entry + 8
                        );

                    break;
                }
            }

            if (
                gpsOffset ===
                null
            ) {
                return null;
            }

            const gpsIfd =
                tiff +
                gpsOffset;

            const gpsCount =
                u16(gpsIfd);

            let latRef = '';
            let lonRef = '';
            let latParts = null;
            let lonParts = null;

            const rationalTriplet =
                function (offset) {
                    const values = [];

                    for (
                        let i = 0;
                        i < 3;
                        i++
                    ) {
                        const numerator =
                            u32(
                                offset +
                                i * 8
                            );

                        const denominator =
                            u32(
                                offset +
                                i * 8 +
                                4
                            );

                        if (
                            denominator ===
                            0
                        ) {
                            throw new Error(
                                'EXIF_ZERO_DENOMINATOR'
                            );
                        }

                        values.push(
                            numerator /
                            denominator
                        );
                    }

                    return values;
                };

            for (
                let i = 0;
                i < gpsCount;
                i++
            ) {
                const entry =
                    gpsIfd +
                    2 +
                    i * 12;

                if (
                    entry + 12 >
                    view.byteLength
                ) {
                    return null;
                }

                const tag =
                    u16(entry);

                const type =
                    u16(
                        entry + 2
                    );

                const count =
                    u32(
                        entry + 4
                    );

                const valueField =
                    entry + 8;

                if (
                    (
                        tag === 1 ||
                        tag === 3
                    ) &&
                    type === 2
                ) {
                    const valueOffset =
                        count <= 4
                            ? valueField
                            : tiff +
                                u32(
                                    valueField
                                );

                    const ref =
                        ascii(
                            view,
                            valueOffset,
                            count
                        )
                            .trim()
                            .toUpperCase();

                    if (
                        tag === 1
                    ) {
                        latRef =
                            ref;
                    } else {
                        lonRef =
                            ref;
                    }
                }

                if (
                    (
                        tag === 2 ||
                        tag === 4
                    ) &&
                    type === 5 &&
                    count >= 3
                ) {
                    const dataOffset =
                        tiff +
                        u32(
                            valueField
                        );

                    const parts =
                        rationalTriplet(
                            dataOffset
                        );

                    if (
                        tag === 2
                    ) {
                        latParts =
                            parts;
                    } else {
                        lonParts =
                            parts;
                    }
                }
            }

            if (
                !latRef ||
                !lonRef ||
                !latParts ||
                !lonParts
            ) {
                return null;
            }

            const decimal =
                function (parts) {
                    return (
                        parts[0] +
                        parts[1] / 60 +
                        parts[2] / 3600
                    );
                };

            let latitude =
                decimal(
                    latParts
                );

            let longitude =
                decimal(
                    lonParts
                );

            if (
                latRef ===
                'S'
            ) {
                latitude *= -1;
            }

            if (
                lonRef ===
                'W'
            ) {
                longitude *= -1;
            }

            if (
                !Number.isFinite(
                    latitude
                ) ||
                !Number.isFinite(
                    longitude
                ) ||
                latitude < -90 ||
                latitude > 90 ||
                longitude < -180 ||
                longitude > 180
            ) {
                return null;
            }

            return {
                latitude:
                    latitude,

                longitude:
                    longitude
            };

        } catch (_) {
            return null;
        }
    }

    function canvasToJpeg(
        canvas,
        quality
    ) {
        return new Promise(
            function (
                resolve,
                reject
            ) {
                canvas.toBlob(
                    function (blob) {
                        if (!blob) {
                            reject(
                                new Error(
                                    'CANVAS_TO_BLOB_FAILED'
                                )
                            );

                            return;
                        }

                        resolve(blob);
                    },
                    'image/jpeg',
                    quality
                );
            }
        );
    }

    function runAsyncGenerator(
        generator
    ) {
        return new Promise(
            function (
                resolve,
                reject
            ) {
                function step(
                    method,
                    value
                ) {
                    let result;

                    try {
                        result =
                            generator[
                                method
                            ](
                                value
                            );
                    } catch (error) {
                        reject(
                            error
                        );

                        return;
                    }

                    if (
                        result.done
                    ) {
                        resolve(
                            result.value
                        );

                        return;
                    }

                    Promise.resolve(
                        result.value
                    ).then(
                        function (
                            nextValue
                        ) {
                            step(
                                'next',
                                nextValue
                            );
                        },
                        function (
                            error
                        ) {
                            step(
                                'throw',
                                error
                            );
                        }
                    );
                }

                step(
                    'next'
                );
            }
        );
    }

    function sanitizeImage(
        file
    ) {
        return runAsyncGenerator(
            (function* () {
        let bitmap = null;

        try {
            try {
                bitmap =
                    yield createImageBitmap(
                        file,
                        {
                            imageOrientation:
                                'from-image'
                        }
                    );
            } catch (_) {
                bitmap =
                    yield createImageBitmap(
                        file
                    );
            }

            let width =
                bitmap.width;

            let height =
                bitmap.height;

            const initialScale =
                Math.min(
                    1,
                    MAX_EDGE /
                        Math.max(
                            width,
                            height
                        )
                );

            width =
                Math.max(
                    1,
                    Math.round(
                        width *
                        initialScale
                    )
                );

            height =
                Math.max(
                    1,
                    Math.round(
                        height *
                        initialScale
                    )
                );

            const qualities = [
                0.90,
                0.82,
                0.74,
                0.66,
                0.58
            ];

            for (
                let resizePass = 0;
                resizePass < 6;
                resizePass++
            ) {
                const canvas =
                    document.createElement(
                        'canvas'
                    );

                canvas.width =
                    width;

                canvas.height =
                    height;

                const context =
                    canvas.getContext(
                        '2d',
                        {
                            alpha: false
                        }
                    );

                if (!context) {
                    throw new Error(
                        'CANVAS_CONTEXT_FAILED'
                    );
                }

                context.fillStyle =
                    '#fff';

                context.fillRect(
                    0,
                    0,
                    width,
                    height
                );

                context.drawImage(
                    bitmap,
                    0,
                    0,
                    width,
                    height
                );

                for (
                    const quality
                    of qualities
                ) {
                    const blob =
                        yield canvasToJpeg(
                            canvas,
                            quality
                        );

                    if (
                        blob.size <=
                        MAX_OUTPUT_BYTES
                    ) {
                        const safeBuffer =
                            yield blob
                                .arrayBuffer();

                        if (
                            jpegExif(
                                safeBuffer
                            )
                        ) {
                            throw new Error(
                                'EXIF_REMAINED_AFTER_SANITIZE'
                            );
                        }

                        return {
                            blob:
                                blob,

                            width:
                                width,

                            height:
                                height,

                            quality:
                                quality
                        };
                    }
                }

                const nextWidth =
                    Math.round(
                        width * 0.85
                    );

                const nextHeight =
                    Math.round(
                        height * 0.85
                    );

                if (
                    Math.max(
                        nextWidth,
                        nextHeight
                    ) <
                    MIN_EDGE
                ) {
                    break;
                }

                width =
                    Math.max(
                        1,
                        nextWidth
                    );

                height =
                    Math.max(
                        1,
                        nextHeight
                    );
            }

            throw new Error(
                'SAFE_JPEG_SIZE_LIMIT_FAILED'
            );

        } finally {
            if (
                bitmap &&
                typeof bitmap.close ===
                    'function'
            ) {
                bitmap.close();
            }
        }
            }())
        );
    }

    function defaultFilename(
        originalName
    ) {
        let base =
            String(
                originalName ||
                ''
            )
                .replace(
                    /\.[^.]*$/,
                    ''
                )
                .replace(
                    /[\\/:*?"<>|#\[\]{}]+/g,
                    '-'
                )
                .replace(
                    /\s+/g,
                    ' '
                )
                .trim();

        if (!base) {
            base =
                'festival-photo';
        }

        return (
            base +
            '.jpg'
        );
    }

    function normalizedFilename(
        value
    ) {
        let name =
            String(
                value ||
                ''
            )
                .trim()
                .replace(
                    /^File:/i,
                    ''
                )
                .replace(
                    /^\u30d5\u30a1\u30a4\u30eb:/,
                    ''
                );

        if (!name) {
            return '';
        }

        name =
            name.replace(
                /[\\/:*?"<>|#\[\]{}]+/g,
                '-'
            );

        name =
            name.replace(
                /\.[^.]*$/,
                ''
            );

        name =
            name.trim();

        if (!name) {
            return '';
        }

        return (
            name +
            '.jpg'
        );
    }

    function makeElement(
        tag,
        properties
    ) {
        const element =
            document.createElement(
                tag
            );

        Object.keys(
            properties || {}
        ).forEach(
            function (key) {
                if (
                    key ===
                    'style'
                ) {
                    element.style.cssText =
                        properties[key];

                    return;
                }

                if (
                    key ===
                    'textContent'
                ) {
                    element.textContent =
                        properties[key];

                    return;
                }

                element[key] =
                    properties[key];
            }
        );

        return element;
    }

    function initUploader() {
        const mainImage =
            document.querySelector(
                MAIN_SELECTOR
            );

        if (!mainImage) {
            return;
        }

        if (
            document.getElementById(
                UI_ID
            )
        ) {
            return;
        }

        const positionStatus =
            document.querySelector(
                POSITION_SELECTOR
            );

        const latitudeInput =
            document.querySelector(
                LAT_SELECTOR
            );

        const longitudeInput =
            document.querySelector(
                LON_SELECTOR
            );

        if (
            !positionStatus ||
            !latitudeInput ||
            !longitudeInput
        ) {
            return;
        }

        const uploadLink =
            document.querySelector(
                '.ext-pageforms-uploadable' +
                '[data-input-id="' +
                CSS.escape(
                    mainImage.id
                ) +
                '"]'
            );

        if (!uploadLink) {
            return;
        }

        /*
         * Privacy fail-closed:
         * once this workflow is detected,
         * hide the standard Page Forms
         * upload route for this field.
         */
        uploadLink.hidden =
            true;

        uploadLink.setAttribute(
            'aria-hidden',
            'true'
        );

        const originalState = {
            mainImage:
                mainImage.value,

            positionStatus:
                positionStatus.value,

            latitude:
                latitudeInput.value,

            longitude:
                longitudeInput.value
        };

        const state = {
            sourceType:
                '',

            gps:
                null,

            gpsFound:
                false,

            gpsAdopted:
                false,

            safeBlob:
                null,

            safeWidth:
                0,

            safeHeight:
                0,

            safeQuality:
                0,

            safeExifPresent:
                null,

            objectUrl:
                '',

            uploadAttempted:
                false,

            uploadPerformed:
                false,

            uploadErrorCode:
                '',

            uploadedFilename:
                ''
        };

        const box =
            makeElement(
                'div',
                {
                    id:
                        UI_ID,

                    style:
                        'margin-top:.75rem;' +
                        'padding:.85rem;' +
                        'border:1px solid #a2a9b1;' +
                        'border-radius:6px;' +
                        'background:#fff;'
                }
            );

        const heading =
            makeElement(
                'strong',
                {
                    textContent:
                        LABELS.title
                }
            );

        const help =
            makeElement(
                'div',
                {
                    textContent:
                        LABELS.help,

                    style:
                        'margin:.4rem 0 .75rem;'
                }
            );

        const fileLabel =
            makeElement(
                'label',
                {
                    textContent:
                        LABELS.select,

                    style:
                        'display:block;' +
                        'font-weight:600;' +
                        'margin-bottom:.25rem;'
                }
            );

        const fileInput =
            makeElement(
                'input',
                {
                    type:
                        'file',

                    accept:
                        'image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp'
                }
            );

        const nameLabel =
            makeElement(
                'label',
                {
                    textContent:
                        LABELS.publicName,

                    style:
                        'display:block;' +
                        'font-weight:600;' +
                        'margin-top:.75rem;' +
                        'margin-bottom:.25rem;'
                }
            );

        const filenameInput =
            makeElement(
                'input',
                {
                    type:
                        'text',

                    style:
                        'box-sizing:border-box;' +
                        'width:100%;' +
                        'max-width:32rem;'
                }
            );

        const status =
            makeElement(
                'div',
                {
                    style:
                        'margin-top:.65rem;'
                }
            );

        const info =
            makeElement(
                'div',
                {
                    style:
                        'margin-top:.35rem;' +
                        'font-size:.95em;'
                }
            );

        const preview =
            makeElement(
                'img',
                {
                    alt:
                        'privacy-safe local preview',

                    hidden:
                        true,

                    style:
                        'display:none;' +
                        'max-width:260px;' +
                        'max-height:360px;' +
                        'object-fit:contain;' +
                        'margin-top:.65rem;'
                }
            );

        const actions =
            makeElement(
                'div',
                {
                    style:
                        'display:flex;' +
                        'flex-wrap:wrap;' +
                        'gap:.5rem;' +
                        'margin-top:.75rem;'
                }
            );

        const adoptButton =
            makeElement(
                'button',
                {
                    type:
                        'button',

                    textContent:
                        LABELS.adoptGps,

                    hidden:
                        true
                }
            );

        const uploadButton =
            makeElement(
                'button',
                {
                    type:
                        'button',

                    textContent:
                        LABELS.upload,

                    disabled:
                        true
                }
            );

        const resetButton =
            makeElement(
                'button',
                {
                    type:
                        'button',

                    textContent:
                        LABELS.reset,

                    disabled:
                        true
                }
            );

        fileLabel.appendChild(
            fileInput
        );

        actions.append(
            adoptButton,
            uploadButton,
            resetButton
        );

        box.append(
            heading,
            help,
            fileLabel,
            nameLabel,
            filenameInput,
            status,
            info,
            preview,
            actions
        );

        const previewWrapper =
            document.getElementById(
                mainImage.id +
                '_imagepreview'
            );

        (
            previewWrapper ||
            uploadLink
        ).insertAdjacentElement(
            'afterend',
            box
        );

        function revokePreview() {
            if (
                state.objectUrl
            ) {
                URL.revokeObjectURL(
                    state.objectUrl
                );

                state.objectUrl =
                    '';
            }
        }

        function resetSelection() {
            revokePreview();

            fileInput.value =
                '';

            filenameInput.value =
                '';

            preview.removeAttribute(
                'src'
            );

            preview.hidden =
                true;

            preview.style.display =
                'none';

            adoptButton.hidden =
                true;

            uploadButton.disabled =
                true;

            resetButton.disabled =
                true;

            state.sourceType =
                '';

            state.gps =
                null;

            state.gpsFound =
                false;

            state.gpsAdopted =
                false;

            state.safeBlob =
                null;

            state.safeWidth =
                0;

            state.safeHeight =
                0;

            state.safeQuality =
                0;

            state.safeExifPresent =
                null;

            state.uploadAttempted =
                false;

            state.uploadPerformed =
                false;

            state.uploadErrorCode =
                '';

            state.uploadedFilename =
                '';

            status.textContent =
                '';

            info.textContent =
                '';
        }

        function safeStateReport() {
            return {
                realUploadEnabled:
                    REAL_UPLOAD_ENABLED,

                sourceType:
                    state.sourceType,

                gpsFound:
                    state.gpsFound,

                gpsAdopted:
                    state.gpsAdopted,

                gpsValuesPrinted:
                    false,

                sanitizedReady:
                    !!state.safeBlob,

                sanitizedType:
                    state.safeBlob
                        ? state.safeBlob.type
                        : '',

                sanitizedSize:
                    state.safeBlob
                        ? state.safeBlob.size
                        : 0,

                sanitizedWidth:
                    state.safeWidth,

                sanitizedHeight:
                    state.safeHeight,

                sanitizedExifApp1Found:
                    state.safeExifPresent,

                outputWithin1536KiB:
                    !!state.safeBlob &&
                    state.safeBlob.size <=
                        MAX_OUTPUT_BYTES,

                publicFilename:
                    normalizedFilename(
                        filenameInput.value
                    ),

                mainImageChanged:
                    mainImage.value !==
                    originalState.mainImage,

                positionStatusChanged:
                    positionStatus.value !==
                    originalState.positionStatus,

                latitudeHasValue:
                    !!latitudeInput.value
                        .trim(),

                longitudeHasValue:
                    !!longitudeInput.value
                        .trim(),

                uploadAttempted:
                    state.uploadAttempted,

                uploadPerformed:
                    state.uploadPerformed,

                uploadErrorCode:
                    state.uploadErrorCode,

                uploadedFilename:
                    state.uploadedFilename,

                standardUploadHidden:
                    uploadLink.hidden ===
                    true
            };
        }

        window
            .__r9PhotoPrivacyUploaderState =
            safeStateReport;

        window
            .__r9PhotoPrivacyUploaderCleanup =
            function () {
                revokePreview();

                mainImage.value =
                    originalState.mainImage;

                positionStatus.value =
                    originalState.positionStatus;

                latitudeInput.value =
                    originalState.latitude;

                longitudeInput.value =
                    originalState.longitude;

                box.remove();

                uploadLink.hidden =
                    false;

                uploadLink.removeAttribute(
                    'aria-hidden'
                );

                delete window
                    .__r9PhotoPrivacyUploaderState;

                delete window
                    .__r9PhotoPrivacyUploaderCleanup;
            };

        fileInput.addEventListener(
            'change',
            function () {
                return runAsyncGenerator(
                    (function* () {
                const file =
                    fileInput.files &&
                    fileInput.files[0];

                if (!file) {
                    return;
                }

                revokePreview();

                state.gps =
                    null;

                state.gpsFound =
                    false;

                state.gpsAdopted =
                    false;

                state.safeBlob =
                    null;

                state.safeExifPresent =
                    null;

                state.uploadAttempted =
                    false;

                state.uploadPerformed =
                    false;

                state.uploadErrorCode =
                    '';

                state.uploadedFilename =
                    '';

                uploadButton.disabled =
                    true;

                resetButton.disabled =
                    false;

                adoptButton.hidden =
                    true;

                preview.hidden =
                    true;

                preview.style.display =
                    'none';

                status.textContent =
                    LABELS.processing;

                info.textContent =
                    '';

                try {
                    const originalBuffer =
                        yield file
                            .arrayBuffer();

                    const imageType =
                        detectImageType(
                            originalBuffer
                        );

                    if (
                        ![
                            'image/jpeg',
                            'image/png',
                            'image/webp'
                        ].includes(
                            imageType
                        )
                    ) {
                        throw new Error(
                            'UNSUPPORTED_IMAGE_TYPE'
                        );
                    }

                    state.sourceType =
                        imageType;

                    state.gps =
                        parseGps(
                            originalBuffer,
                            imageType
                        );

                    state.gpsFound =
                        !!state.gps;

                    const safe =
                        yield sanitizeImage(
                            file
                        );

                    state.safeBlob =
                        safe.blob;

                    state.safeWidth =
                        safe.width;

                    state.safeHeight =
                        safe.height;

                    state.safeQuality =
                        safe.quality;

                    const safeBuffer =
                        yield safe.blob
                            .arrayBuffer();

                    state.safeExifPresent =
                        !!jpegExif(
                            safeBuffer
                        );

                    if (
                        state.safeExifPresent
                    ) {
                        throw new Error(
                            'SANITIZED_JPEG_HAS_EXIF'
                        );
                    }

                    filenameInput.value =
                        defaultFilename(
                            file.name
                        );

                    state.objectUrl =
                        URL.createObjectURL(
                            safe.blob
                        );

                    preview.src =
                        state.objectUrl;

                    preview.hidden =
                        false;

                    preview.style.display =
                        'block';

                    adoptButton.hidden =
                        !state.gpsFound;

                    uploadButton.disabled =
                        false;

                    status.textContent =
                        state.gpsFound
                            ? LABELS.gpsFound
                            : LABELS.gpsNotFound;

                    info.textContent =
                        LABELS.ready +
                        ' ' +
                        safe.width +
                        ' x ' +
                        safe.height +
                        ' / ' +
                        Math.ceil(
                            safe.blob.size /
                            1024
                        ) +
                        ' KiB';

                } catch (error) {
                    state.safeBlob =
                        null;

                    uploadButton.disabled =
                        true;

                    adoptButton.hidden =
                        true;

                    preview.hidden =
                        true;

                    preview.style.display =
                        'none';

                    if (
                        error &&
                        error.message ===
                            'UNSUPPORTED_IMAGE_TYPE'
                    ) {
                        status.textContent =
                            LABELS.badType;
                    } else {
                        status.textContent =
                            'R9 local processing error: ' +
                            (
                                error &&
                                error.message
                                    ? error.message
                                    : 'UNKNOWN'
                            );
                    }
                }
                    }())
                );
            }
        );

        adoptButton.addEventListener(
            'click',
            function () {
                if (!state.gps) {
                    return;
                }

                const existingCoordinates =
                    !!latitudeInput.value
                        .trim() ||
                    !!longitudeInput.value
                        .trim();

                if (
                    existingCoordinates &&
                    !window.confirm(
                        LABELS.replaceCoords
                    )
                ) {
                    return;
                }

                latitudeInput.value =
                    state.gps
                        .latitude
                        .toFixed(8);

                longitudeInput.value =
                    state.gps
                        .longitude
                        .toFixed(8);

                dispatchInputChange(
                    latitudeInput
                );

                dispatchInputChange(
                    longitudeInput
                );

                state.gpsAdopted =
                    true;

                status.textContent =
                    LABELS.gpsApplied;
            }
        );

        resetButton.addEventListener(
            'click',
            function () {
                resetSelection();
            }
        );

        uploadButton.addEventListener(
            'click',
            function () {
                return runAsyncGenerator(
                    (function* () {
                if (!state.safeBlob) {
                    return;
                }

                if (
                    !REAL_UPLOAD_ENABLED
                ) {
                    state.uploadAttempted =
                        false;

                    state.uploadPerformed =
                        false;

                    status.textContent =
                        LABELS.dryRun;

                    return;
                }

                const filename =
                    normalizedFilename(
                        filenameInput.value
                    );

                if (!filename) {
                    status.textContent =
                        LABELS.badName;

                    return;
                }

                state.uploadAttempted =
                    true;

                state.uploadPerformed =
                    false;

                state.uploadErrorCode =
                    '';

                state.uploadedFilename =
                    '';

                uploadButton.disabled =
                    true;

                fileInput.disabled =
                    true;

                filenameInput.disabled =
                    true;

                try {
                    const api =
                        new mw.Api();

                    const query =
                        yield api.get({
                            action:
                                'query',

                            titles:
                                'File:' +
                                filename,

                            prop:
                                'imageinfo',

                            iiprop:
                                'url|size|sha1',

                            formatversion:
                                2
                        });

                    const page =
                        query &&
                        query.query &&
                        query.query.pages &&
                        query.query.pages[0]
                            ? query.query.pages[0]
                            : {};

                    if (
                        page.missing !==
                        true
                    ) {
                        state.uploadErrorCode =
                            'FILE_ALREADY_EXISTS';

                        status.textContent =
                            LABELS.existingFile;

                        return;
                    }

                    const result =
                        yield new Promise(
                            function (
                                resolve,
                                reject
                            ) {
                                api.upload(
                                    state.safeBlob,
                                    {
                                        filename:
                                            filename,

                                        comment:
                                            'Uploaded via MatsuriWiki privacy-safe photo uploader'
                                    }
                                )
                                    .done(
                                        function (
                                            data
                                        ) {
                                            resolve(
                                                data
                                            );
                                        }
                                    )
                                    .fail(
                                        function (
                                            code,
                                            data
                                        ) {
                                            reject({
                                                code:
                                                    code,

                                                data:
                                                    data
                                            });
                                        }
                                    );
                            }
                        );

                    const upload =
                        result &&
                        result.upload
                            ? result.upload
                            : null;

                    if (
                        !upload ||
                        upload.result !==
                            'Success'
                    ) {
                        throw {
                            code:
                                'UPLOAD_NOT_SUCCESS',

                            data:
                                result
                        };
                    }

                    const uploadedFilename =
                        upload.filename ||
                        filename;

                    /*
                     * This is intentionally the
                     * only main_image write path.
                     * It is reached only after
                     * MediaWiki reports upload
                     * success.
                     */
                    mainImage.value =
                        uploadedFilename;

                    dispatchInputChange(
                        mainImage
                    );

                    state.uploadPerformed =
                        true;

                    state.uploadedFilename =
                        uploadedFilename;

                    status.textContent =
                        LABELS.uploadSuccess;

                } catch (error) {
                    state.uploadPerformed =
                        false;

                    state.uploadErrorCode =
                        error &&
                        error.code
                            ? String(
                                error.code
                            )
                            : 'UNKNOWN';

                    const warnings =
                        error && error.data && error.data.upload
                            ? error.data.upload.warnings
                            : null;

                    if (
                        warnings &&
                        Object.prototype.hasOwnProperty.call(
                            warnings, 'bad-prefix'
                        )
                    ) {
                        state.uploadErrorCode = 'bad-prefix';
                        status.textContent =
                            '内容が分かる公開ファイル名に変更してください';
                    } else {
                        status.textContent =
                            LABELS.uploadFailure +
                            ' [' +
                            state.uploadErrorCode +
                            ']';
                    }

                } finally {
                    uploadButton.disabled =
                        !state.safeBlob;

                    fileInput.disabled =
                        false;

                    filenameInput.disabled =
                        false;
                }
                    }())
                );
            }
        );

        console.log({
            R9_3_PRIVACY_UPLOADER_READY:
                true,

            realUploadEnabled:
                REAL_UPLOAD_ENABLED,

            standardUploadHidden:
                uploadLink.hidden,

            mainImagePreserved:
                mainImage.value ===
                originalState.mainImage,

            positionStatusPreserved:
                positionStatus.value ===
                originalState.positionStatus,

            gpsValuesPrinted:
                false
        });
    }

    mw.loader
        .using(
            'mediawiki.api'
        )
        .then(
            function () {
                if (
                    document.readyState ===
                    'loading'
                ) {
                    document.addEventListener(
                        'DOMContentLoaded',
                        initUploader,
                        {
                            once:
                                true
                        }
                    );
                } else {
                    initUploader();
                }

                if (
                    mw.hook
                ) {
                    mw.hook(
                        'wikipage.content'
                    ).add(
                        initUploader
                    );
                }
            }
        );
}());

/* =====================================
 * R12-01 Stall new-form name autofill
 * ===================================== */
( function () {
    'use strict';

    function initR12StallNameAutofill() {
        const canonicalSpecial =
            mw.config.get(
                'wgCanonicalSpecialPageName'
            );

        const targetName =
            String(
                mw.config.get(
                    'wgPageFormsTargetName'
                ) || ''
            ).trim();

        const form =
            document.querySelector(
                '#pfForm'
            );

        const nameField =
            form
                ? form.querySelector(
                    '[name="Stall[name]"]'
                )
                : null;

        if (
            canonicalSpecial !== 'FormEdit' ||
            targetName === '' ||
            targetName === 'Dummy title' ||
            !nameField ||
            nameField.value.trim() !== ''
        ) {
            return;
        }

        nameField.value =
            targetName;

        nameField.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );

        nameField.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }

    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            initR12StallNameAutofill,
            {
                once: true
            }
        );
    } else {
        initR12StallNameAutofill();
    }
}() );

/*
 * R12-03G2 屋台種類カテゴリ絞り込み
 *
 * FestivalStallPlacement の屋台種類 combobox に、
 * 保存されないカテゴリ絞り込み UI を追加する。
 *
 * - カテゴリは Cargo Stalls.category から動的取得
 * - 「すべて」では通常の Page Forms autocomplete
 * - カテゴリ選択時は Page Forms 標準 dependent Cargo autocomplete
 * - helper field は proxy form に所属させ、#pfForm には送信しない
 */
( function () {
	'use strict';

	const STALL_FIELD = 'FestivalStallPlacement[stall_id]';
	const TARGET_NAME = 'R12StallSearchTarget';
	const CATEGORY_NAME = 'R12StallCategoryFilter';

	const PROXY_FORM_ID = 'r12-stall-filter-proxy-form';
	const FILTER_ID = 'r12-stall-category-filter';
	const ALL_VALUE = '__all__';

	const DEPENDENT_PAIR = [
		CATEGORY_NAME,
		TARGET_NAME
	];

	function isOurDependentPair( pair ) {
		return (
			Array.isArray( pair ) &&
			pair.length >= 2 &&
			pair[ 0 ] === CATEGORY_NAME &&
			pair[ 1 ] === TARGET_NAME
		);
	}

	function setDependentFilterEnabled( enabled ) {
		const current =
			mw.config.get( 'wgPageFormsDependentFields' ) || [];

		const next = current.filter( function ( pair ) {
			return !isOurDependentPair( pair );
		} );

		if ( enabled ) {
			next.push( [
				CATEGORY_NAME,
				TARGET_NAME
			] );
		}

		mw.config.set(
			'wgPageFormsDependentFields',
			next
		);
	}

	function extractCategories( response ) {
		const rows =
			response &&
			Array.isArray( response.cargoquery )
				? response.cargoquery
				: [];

		const seen = new Set();

		rows.forEach( function ( row ) {
			const value =
				row &&
				row.title
					? row.title.category
					: null;

			if (
				typeof value === 'string' &&
				value.trim() !== ''
			) {
				seen.add(
					value.trim()
				);
			}
		} );

		return Array.from( seen );
	}

	function createProxyForm() {
		let proxy =
			document.getElementById(
				PROXY_FORM_ID
			);

		if ( proxy ) {
			return proxy;
		}

		proxy =
			document.createElement(
				'form'
			);

		proxy.id =
			PROXY_FORM_ID;

		proxy.hidden =
			true;

		document.body.appendChild(
			proxy
		);

		return proxy;
	}

	function installFilter(
		pfForm,
		hidden,
		span,
		visible,
		categories
	) {
		if (
			document.getElementById(
				FILTER_ID
			)
		) {
			return;
		}

		const cell =
			span.closest(
				'td'
			);

		if ( !cell ) {
			return;
		}

		createProxyForm();

		const wrapper =
			document.createElement(
				'div'
			);

		wrapper.id =
			FILTER_ID;

		wrapper.style.marginBottom =
			'10px';

		const label =
			document.createElement(
				'label'
			);

		label.textContent =
			'カテゴリで絞り込み(任意)';

		label.style.display =
			'block';

		label.style.fontWeight =
			'600';

		label.style.marginBottom =
			'4px';

		const select =
			document.createElement(
				'select'
			);

		select.name =
			CATEGORY_NAME;

		select.setAttribute(
			'form',
			PROXY_FORM_ID
		);

		select.setAttribute(
			'autocompletesettings',
			'Stalls|category'
		);

		select.setAttribute(
			'aria-label',
			'屋台の種類をカテゴリで絞り込み'
		);

		select.style.width =
			'100%';

		select.style.maxWidth =
			'100%';

		select.style.boxSizing =
			'border-box';

		const allOption =
			document.createElement(
				'option'
			);

		allOption.value =
			ALL_VALUE;

		allOption.textContent =
			'すべて';

		select.appendChild(
			allOption
		);

		categories.forEach( function ( category ) {
			const option =
				document.createElement(
					'option'
				);

			option.value =
				category;

			option.textContent =
				category;

			select.appendChild(
				option
			);
		} );

		const help =
			document.createElement(
				'div'
			);

		help.className =
			'stall-form-help';

		help.textContent =
			'カテゴリを選ぶと、屋台の種類の検索候補を絞り込めます。';

		wrapper.appendChild(
			label
		);

		wrapper.appendChild(
			select
		);

		wrapper.appendChild(
			help
		);

		/*
		 * Page Forms dependentOn() が
		 * visible combobox を識別できるようにする。
		 *
		 * form 属性を proxy form に向けるため、
		 * TARGET_NAME は #pfForm の FormData には入らない。
		 */
		visible.setAttribute(
			'name',
			TARGET_NAME
		);

		visible.setAttribute(
			'form',
			PROXY_FORM_ID
		);

		/*
		 * R12-03G2-R2
		 *
		 * Page Forms の Cargo remote autocomplete は、
		 * autocompletedatatype='cargo field' の場合、
		 * 空文字で dependent autocomplete に到達する前に
		 * 「1文字以上入力してください」で終了する。
		 *
		 * 実カテゴリが選択されているこの屋台種類欄だけ、
		 * setValues() 実行中に autocompletedatatype を
		 * 一時的に undefined にし、Page Forms 標準の
		 * dependent Cargo autocomplete 経路へ通す。
		 *
		 * 「すべて」では従来の Cargo autocomplete を維持する。
		 */
		const comboPrototype =
			window.pf &&
			window.pf.ComboBoxInput &&
			window.pf.ComboBoxInput.prototype;

		if (
			comboPrototype &&
			typeof comboPrototype.setValues === 'function' &&
			comboPrototype.__r12StallDependentCargoBridge !== '1'
		) {
			const originalSetValues =
				comboPrototype.setValues;

			comboPrototype.setValues =
				function () {
					const args =
						arguments;

					const category =
						document.querySelector(
							'[name="' +
							CATEGORY_NAME +
							'"]'
						);

					const isTarget =
						this.config &&
						this.config.autocompletesettings ===
							'Stalls|name' &&
						typeof this.dependentOn ===
							'function' &&
						this.dependentOn() ===
							CATEGORY_NAME;

					const specificCategory =
						category &&
						category.value &&
						category.value !==
							ALL_VALUE;

					if (
						isTarget &&
						specificCategory &&
						this.config.autocompletedatatype ===
							'cargo field'
					) {
						const originalDatatype =
							this.config.autocompletedatatype;

						try {
							this.config.autocompletedatatype =
								undefined;

							return originalSetValues.apply(
								this,
								args
							);
						} finally {
							this.config.autocompletedatatype =
								originalDatatype;
						}
					}

					return originalSetValues.apply(
						this,
						args
					);
				};

			comboPrototype.__r12StallDependentCargoBridge =
				'1';
		}

		select.addEventListener(
			'change',
			function () {
				const filterEnabled =
					select.value !==
						ALL_VALUE;

				setDependentFilterEnabled(
					filterEnabled
				);

				/*
				 * 実カテゴリへ切り替えた場合、
				 * 以前のカテゴリの屋台名を検索文字列として
				 * dependent autocomplete に渡さない。
				 *
				 * 本物の hidden input も空にし、
				 * 新しいカテゴリから選び直してもらう。
				 */
				if ( filterEnabled ) {
					hidden.value =
						'';

					visible.value =
						'';

					visible.setAttribute(
						'data-value',
						''
					);

					visible.setAttribute(
						'data-label',
						''
					);

					visible.setAttribute(
						'title',
						''
					);
				}
			}
		);

		/*
		 * 初期状態は「すべて」。
		 */
		setDependentFilterEnabled(
			false
		);

		cell.insertBefore(
			wrapper,
			span
		);

		span.dataset.r12StallCategoryFilter =
			'1';

		/*
		 * Safety invariant:
		 * 本物の stall_id hidden input は
		 * #pfForm に所属したまま。
		 */
		if (
			hidden.form !== pfForm
		) {
			mw.log.warn(
				'R12-03G2: stall_id form ownership changed unexpectedly.'
			);
		}
	}

	function initialize() {
		const pfForm =
			document.querySelector(
				'#pfForm'
			);

		if ( !pfForm ) {
			return false;
		}

		const hidden =
			pfForm.querySelector(
				'input[type="hidden"][name="' +
				STALL_FIELD +
				'"]'
			);

		if ( !hidden ) {
			return false;
		}

		const span =
			hidden.closest(
				'.comboboxSpan'
			);

		if (
			!span ||
			span.dataset.r12StallCategoryFilter ===
				'1' ||
			span.dataset.r12StallCategoryFilterLoading ===
				'1'
		) {
			return !!span;
		}

		const visible =
			span.querySelector(
				'input[role="combobox"]'
			);

		if (
			!visible ||
			visible.getAttribute(
				'autocompletesettings'
			) !== 'Stalls|name'
		) {
			return false;
		}

		span.dataset.r12StallCategoryFilterLoading =
			'1';

		mw.loader.using(
			'mediawiki.api'
		).then( function () {
			const api =
				new mw.Api();

			return api.get( {
				action:
					'cargoquery',

				tables:
					'Stalls',

				fields:
					'category',

				where:
					"category IS NOT NULL AND category != ''",

				group_by:
					'category',

				order_by:
					'category',

				limit:
					500,

				format:
					'json'
			} );

		} ).then( function ( response ) {
			const categories =
				extractCategories(
					response
				);

			if (
				categories.length === 0
			) {
				return;
			}

			installFilter(
				pfForm,
				hidden,
				span,
				visible,
				categories
			);

		} ).catch( function ( error ) {
			mw.log.warn(
				'R12-03G2: category filter initialization failed.',
				error
			);

		} ).always( function () {
			delete span.dataset
				.r12StallCategoryFilterLoading;
		} );

		return true;
	}

	function start() {
		let attempts =
			0;

		const maxAttempts =
			50;

		function tryInitialize() {
			attempts +=
				1;

			if (
				initialize() ||
				attempts >=
					maxAttempts
			) {
				return;
			}

			window.setTimeout(
				tryInitialize,
				100
			);
		}

		tryInitialize();
	}

	if (
		document.readyState ===
			'loading'
	) {
		document.addEventListener(
			'DOMContentLoaded',
			start,
			{
				once:
					true
			}
		);
	} else {
		start();
	}

}() );

/* =========================================
 * FestivalTemporaryFacility:
 * 祭り → 会場候補連動
 * ========================================= */
$(function () {
    function setupFestivalVenueFilter() {
        const festivalSelect =
            document.querySelector(
                'input[type="hidden"][name="FestivalTemporaryFacility[festival_id]"]'
            ) ||
            document.querySelector(
                'select[name="FestivalTemporaryFacility[festival_id]"]:not(.pfComboBox)'
            );

        const venueSelect =
            document.querySelector(
                'select[name="FestivalTemporaryFacility[venue_id]"]'
            );

        if (!festivalSelect || !venueSelect) {
            return;
        }

        if (
            venueSelect.dataset.r5FestivalVenueFilter ===
            '1'
        ) {
            return;
        }

        venueSelect.dataset.r5FestivalVenueFilter =
            '1';

        const api = new mw.Api();

        const originalOptions =
            Array.from(
                venueSelect.options
            ).map(function (option) {
                return option.cloneNode(true);
            });

        const initialFestival =
            festivalSelect.value.trim();

        const initialVenue =
            venueSelect.value.trim();

        let requestId = 0;

        function escapeCargoValue(value) {
            return String(value).replace(
                /'/g,
                "''"
            );
        }

        function getBlankOption(label) {
            let blank =
                originalOptions.find(function (option) {
                    return option.value === '';
                });

            if (blank) {
                blank=blank.cloneNode(true);
            } else {
                blank=document.createElement(
                    'option'
                );
                blank.value='';
            }

            blank.textContent=label;

            return blank;
        }

        function findOriginalOption(value) {
            const option =
                originalOptions.find(
                    function (item) {
                        return item.value === value;
                    }
                );

            if (option) {
                return option.cloneNode(true);
            }

            const dynamicOption =
                document.createElement(
                    'option'
                );

            dynamicOption.value =
                value;

            dynamicOption.textContent =
                value;

            dynamicOption.setAttribute(
                'data-r14-dynamic-venue-option',
                '1'
            );

            return dynamicOption;
        }

        function dispatchVenueChange(
            preservePlacementCoordinates
        ) {
            venueSelect.dispatchEvent(
                new CustomEvent(
                    'change',
                    {
                        bubbles: true,
                        detail: {
                            matsuriPreservePlacementCoordinates:
                                preservePlacementCoordinates === true
                        }
                    }
                )
            );
        }

        function replaceOptions(
            venuePages,
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const oldValue =
                preserveCurrent
                    ? initialVenue
                    : '';

            const fragment =
                document.createDocumentFragment();

            fragment.appendChild(
                getBlankOption('未指定')
            );

            venuePages.forEach(function (page) {
                let option =
                    findOriginalOption(page);

                if (!option) {
                    console.warn(
                        'Page Formsの元候補に会場がありません。',
                        page
                    );

                    return;
                }

                option.selected=false;
                fragment.appendChild(option);
            });

            if (
                preserveCurrent &&
                oldValue !== '' &&
                !venuePages.includes(oldValue)
            ) {
                const currentOption =
                    findOriginalOption(oldValue);

                if (currentOption) {
                    currentOption.textContent +=
                        '(現在登録値)';

                    fragment.appendChild(
                        currentOption
                    );
                }
            }

            venueSelect.replaceChildren(
                fragment
            );

            let nextValue='';

            if (
                preserveCurrent &&
                oldValue !== '' &&
                Array.from(
                    venueSelect.options
                ).some(function (option) {
                    return option.value ===
                        oldValue;
                })
            ) {
                nextValue=oldValue;
            }

            venueSelect.value=nextValue;
            venueSelect.disabled=false;

            dispatchVenueChange(
                preservePlacementCoordinates
            );
        }

        function showLoading() {
            venueSelect.replaceChildren(
                getBlankOption(
                    '会場候補を読み込み中…'
                )
            );

            venueSelect.disabled=true;
        }

        function showFailure(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const fragment =
                document.createDocumentFragment();

            fragment.appendChild(
                getBlankOption(
                    '未指定(候補取得失敗)'
                )
            );

            if (
                preserveCurrent &&
                initialVenue !== ''
            ) {
                const current =
                    findOriginalOption(
                        initialVenue
                    );

                if (current) {
                    current.textContent +=
                        '(現在登録値)';

                    current.selected=true;

                    fragment.appendChild(
                        current
                    );
                }
            }

            venueSelect.replaceChildren(
                fragment
            );

            venueSelect.disabled=false;

            dispatchVenueChange(
                preservePlacementCoordinates
            );
        }

        function loadVenues(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const festivalValue =
                festivalSelect.value.trim();

            const currentRequest =
                ++requestId;

            if (festivalValue === '') {
                venueSelect.replaceChildren(
                    getBlankOption('未指定')
                );

                venueSelect.disabled=false;

                dispatchVenueChange(
                    preservePlacementCoordinates
                );

                return;
            }

            showLoading();

            const escaped =
                escapeCargoValue(
                    festivalValue
                );

            api.get({
                action:'cargoquery',
                format:'json',
                tables:
                    'Festivals=F,' +
                    'FestivalVenues=FV,' +
                    'Venues=V',
                fields:
                    'V._pageName=venue_page,' +
                    'V.name=venue_name,' +
                    'FV.sort_order=sort_order',
                join_on:
                    'F.festival_id=FV.festival_id,' +
                    'FV.venue_id=V.venue_id',
                where:
                    "(" +
                    "F.name='" +
                    escaped +
                    "' OR " +
                    "F._pageName='" +
                    escaped +
                    "'" +
                    ")",
                order_by:
                    'FV.sort_order ASC,' +
                    'V.name ASC',
                limit:'100'
            }).then(function (data) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }

                const rows =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];

                const venuePages=[];

                rows.forEach(function (result) {
                    const row =
                        result.title ||
                        result;

                    const page =
                        row.venue_page ===
                            undefined ||
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();

                    if (
                        page !== '' &&
                        !venuePages.includes(page)
                    ) {
                        venuePages.push(page);
                    }
                });

                replaceOptions(
                    venuePages,
                    preserveCurrent,
                    preservePlacementCoordinates
                );

                console.log(
                    '祭り連動会場候補を更新しました。',
                    {
                        festival:
                            festivalValue,
                        venues:
                            venuePages
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }

                console.error(
                    '祭り連動会場候補の取得に失敗しました。',
                    error
                );

                showFailure(
                    preserveCurrent,
                    preservePlacementCoordinates
                );
            });
        }

        festivalSelect.addEventListener(
            'change',
            function () {
                loadVenues(
                    false,
                    false
                );
            }
        );

        loadVenues(
            festivalSelect.value.trim() ===
                initialFestival &&
            initialVenue !== '',
            true
        );
    }

    var festivalVenueFilterRetryTimer =
        null;

    function startFestivalVenueFilterSetup() {
        var attempts = 0;
        var maxAttempts = 50;

        if (
            !document.querySelector(
                'select[name="FestivalTemporaryFacility[venue_id]"]'
            )
        ) {
            return;
        }

        if (
            festivalVenueFilterRetryTimer !==
            null
        ) {
            return;
        }

        function trySetup() {
            var venueSelect;

            festivalVenueFilterRetryTimer =
                null;

            setupFestivalVenueFilter();

            venueSelect =
                document.querySelector(
                    'select[name="FestivalTemporaryFacility[venue_id]"]'
                );

            if (
                venueSelect &&
                venueSelect.getAttribute(
                    'data-r5-festival-venue-filter'
                ) === '1'
            ) {
                return;
            }

            attempts += 1;

            if (attempts >= maxAttempts) {
                console.warn(
                    '[R14-02] FestivalTemporaryFacility ' +
                    'festival/venue filter initialization timed out.'
                );
                return;
            }

            festivalVenueFilterRetryTimer =
                window.setTimeout(
                    trySetup,
                    100
                );
        }

        trySetup();
    }

    startFestivalVenueFilterSetup();

    mw.hook(
        'pf.formSetupAfter'
    ).add(
        startFestivalVenueFilterSetup
    );
});


/* =========================================
 * FestivalTemporaryFacility:
 * 会場連動地図ピン → 緯度・経度
 * ========================================= */
$(function () {
    const venueSelect = document.querySelector(
        'select[name="FestivalTemporaryFacility[venue_id]"]'
    );

    const latInput = document.querySelector(
        'input[name="FestivalTemporaryFacility[latitude]"]'
    );

    const lonInput = document.querySelector(
        'input[name="FestivalTemporaryFacility[longitude]"]'
    );

    if (
        !venueSelect ||
        !latInput ||
        !lonInput
    ) {
        return;
    }

    mw.loader.using(
        'ext.pageforms.leaflet'
    ).then(function () {
        if (
            document.getElementById(
                'matsuri-temporary-facility-location-map'
            )
        ) {
            return;
        }

        const api = new mw.Api();

        const mapDiv =
            document.createElement('div');

        mapDiv.id =
            'matsuri-temporary-facility-location-map';

        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';

        const help =
            document.createElement('div');

        help.textContent =
            '会場を選択すると会場周辺を表示します。' +
            '地図をクリックして実際の臨時設備位置を指定してください。' +
            'ピンはドラッグして微調整できます。';

        help.style.marginBottom = '8px';

        const wrapper =
            document.createElement('div');

        wrapper.appendChild(help);
        wrapper.appendChild(mapDiv);

        const latRow =
            latInput.closest('tr');

        if (
            !latRow ||
            !latRow.parentNode
        ) {
            return;
        }

        const mapRow =
            document.createElement('tr');

        const th =
            document.createElement('th');

        th.textContent =
            '臨時設備の位置を地図から選択';

        const td =
            document.createElement('td');

        td.appendChild(wrapper);

        mapRow.appendChild(th);
        mapRow.appendChild(td);

        latRow.parentNode.insertBefore(
            mapRow,
            latRow
        );

        /*
         * 初期状態は日本全体。
         *
         * 既存Placementに座標がある場合は
         * 後でその位置へ移動する。
         */
        const map = L.map(
            mapDiv
        ).setView(
            [ 36.2048, 138.2529 ],
            5
        );

        L.tileLayer(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);

        let marker = null;
        let venueRequestId = 0;

        const venueStatus =
            document.createElement(
                'div'
            );

        venueStatus.className =
            'matsuri-venue-location-status';

        venueStatus.setAttribute(
            'aria-live',
            'polite'
        );

        venueStatus.style.marginBottom =
            '8px';

        if (mapDiv.parentNode) {
            mapDiv.parentNode.insertBefore(
                venueStatus,
                mapDiv
            );
        }

        function setVenueStatus(message) {
            venueStatus.textContent =
                message;
        }

        function resetVenueView() {
            map.setView(
                [ 36.2048, 138.2529 ],
                5
            );
        }

        function dispatchInputEvents(input) {
            input.dispatchEvent(
                new Event(
                    'input',
                    { bubbles: true }
                )
            );

            input.dispatchEvent(
                new Event(
                    'change',
                    { bubbles: true }
                )
            );
        }

        function updateInputs(lat, lon) {
            latInput.value =
                Number(lat).toFixed(6);

            lonInput.value =
                Number(lon).toFixed(6);

            /*
             * 既存の必須・日本範囲チェックを
             * そのまま発火させる。
             */
            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }

        /*
         * R10-5C ISSUE-06B:
         * PageForms配下のLeaflet default PNGは
         * この環境ではHTMLへredirectされるため、
         * 外部画像に依存しないdivIconを使用。
         */
        const placementMarkerIcon =
            L.divIcon({
                className:
                    'matsuri-temporary-facility-marker-icon',

                html:
                    '<svg xmlns="http://www.w3.org/2000/svg" ' +
                    'width="28" height="42" viewBox="0 0 28 42" ' +
                    'aria-hidden="true" focusable="false">' +
                    '<path d="M14 1C6.8 1 1 6.8 1 14c0 10 13 27 13 27s13-17 13-27C27 6.8 21.2 1 14 1Z" ' +
                    'fill="#2a81cb" stroke="#ffffff" stroke-width="2"/>' +
                    '<circle cx="14" cy="14" r="5" fill="#ffffff"/>' +
                    '</svg>',

                iconSize:
                    [
                        28,
                        42
                    ],

                iconAnchor:
                    [
                        14,
                        40
                    ]
            });

        function createMarker(latlng) {
            marker = L.marker(
                latlng,
                {
                    draggable:
                        true,

                    icon:
                        placementMarkerIcon
                }
            ).addTo(map);

            marker.on(
                'dragend',
                function () {
                    const position =
                        marker.getLatLng();

                    updateInputs(
                        position.lat,
                        position.lng
                    );
                }
            );
        }

        function placeMarker(latlng) {
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                createMarker(latlng);
            }

            updateInputs(
                latlng.lat,
                latlng.lng
            );
        }

        function removeMarker() {
            if (!marker) {
                return;
            }

            map.removeLayer(marker);
            marker = null;
        }

        function clearCoordinates() {
            latInput.value = '';
            lonInput.value = '';

            dispatchInputEvents(latInput);
            dispatchInputEvents(lonInput);
        }

        function getCurrentCoordinates() {
            const lat =
                Number(latInput.value);

            const lon =
                Number(lonInput.value);

            if (
                latInput.value.trim() === '' ||
                lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return null;
            }

            return {
                lat: lat,
                lng: lon
            };
        }

        function escapeCargoValue(value) {
            return String(value)
                .replace(
                    /'/g,
                    "''"
                );
        }

        /*
         * 選択されたVenueの座標へ
         * 地図だけ移動する。
         *
         * Placementのlatitude/longitudeには
         * コピーしない。
         */
        function centerOnVenue() {
            const currentRequest =
                ++venueRequestId;

            const venuePage =
                venueSelect.value.trim();

            if (venuePage === '') {
                resetVenueView();

                setVenueStatus(
                    '会場は未指定です。' +
                    '地図上で場所を指定できます。'
                );

                return;
            }

            setVenueStatus(
                '選択した会場の位置情報を確認しています。'
            );

            api.get({
                action: 'cargoquery',
                format: 'json',
                tables: 'Venues',
                fields:
                    'venue_id=venue_id,' +
                    '_pageName=page_name,' +
                    'latitude=latitude,' +
                    'longitude=longitude',
                where:
                    "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
            }).then(function (data) {
                /*
                 * 連続して会場を変更した場合、
                 * 古いレスポンスを無視する。
                 */
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }

                const result =
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];

                if (result.length === 0) {
                    resetVenueView();

                    setVenueStatus(
                        '会場情報を取得できませんでした。' +
                        '地図上で場所を指定できます。'
                    );

                    console.warn(
                        '会場情報を取得できませんでした。',
                        venuePage
                    );

                    return;
                }

                const row =
                    result[0].title ||
                    result[0];

                const lat =
                    Number(row.latitude);

                const lon =
                    Number(row.longitude);

                if (
                    row.latitude === undefined ||
                    row.latitude === null ||
                    String(
                        row.latitude
                    ).trim() === '' ||
                    row.longitude === undefined ||
                    row.longitude === null ||
                    String(
                        row.longitude
                    ).trim() === '' ||
                    Number.isNaN(lat) ||
                    Number.isNaN(lon)
                ) {
                    resetVenueView();

                    setVenueStatus(
                        'この会場は位置情報未登録です。' +
                        '地図上で場所を指定できます。'
                    );

                    console.warn(
                        '選択した会場には座標が登録されていません。',
                        venuePage
                    );

                    return;
                }

                map.setView(
                    [ lat, lon ],
                    18
                );

                setVenueStatus(
                    '選択した会場の位置を表示しています。' +
                    '必要に応じて地図上で実際の位置を指定してください。'
                );

                console.log(
                    '会場位置へ地図を移動しました。',
                    {
                        venue: venuePage,
                        latitude: lat,
                        longitude: lon
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }

                resetVenueView();

                setVenueStatus(
                    '会場位置の取得に失敗しました。' +
                    '地図上で場所を指定できます。'
                );

                console.error(
                    '会場座標の取得に失敗しました。',
                    error
                );
            });
        }


        /*
         * 地図クリック
         */
        map.on(
            'click',
            function (event) {
                placeMarker(
                    event.latlng
                );
            }
        );

        /*
         * 手入力された場合もピンを同期。
         */
        function syncMarkerFromInputs() {
            const coordinates =
                getCurrentCoordinates();

            if (!coordinates) {
                return;
            }

            /*
             * User-selected / manually-entered coordinates
             * take priority over a late Venue response.
             */
            venueRequestId += 1;

            setVenueStatus(
                '指定した位置を地図に表示しています。'
            );

            if (marker) {
                marker.setLatLng(
                    coordinates
                );
            } else {
                createMarker(
                    coordinates
                );
            }

            map.setView(
                [
                    coordinates.lat,
                    coordinates.lng
                ],
                18
            );
        }

        latInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        lonInput.addEventListener(
            'change',
            syncMarkerFromInputs
        );

        /*
         * 会場を変更した場合。
         *
         * 前の会場用の屋台座標を
         * 誤って残さないようクリアする。
         */
        venueSelect.addEventListener(
            'change',
            function (event) {
                const preservePlacementCoordinates =
                    !!(
                        event &&
                        event.detail &&
                        event.detail
                            .matsuriPreservePlacementCoordinates ===
                            true
                    );

                /*
                 * Even when the new Venue is blank,
                 * invalidate an older Cargo response.
                 */
                venueRequestId += 1;

                if (
                    preservePlacementCoordinates
                ) {
                    /*
                     * Existing Placement coordinates
                     * take priority over Venue center.
                     *
                     * If there are no Placement
                     * coordinates, Venue is still a
                     * useful map starting point.
                     */
                    if (
                        !getCurrentCoordinates()
                    ) {
                        centerOnVenue();
                    }

                    return;
                }

                removeMarker();
                clearCoordinates();
                centerOnVenue();
            }
        );

        /*
         * 編集時:
         * 既存Placement座標を優先。
         *
         * 新規時:
         * Venue座標へ地図を移動。
         */
        const initialCoordinates =
            getCurrentCoordinates();

        if (initialCoordinates) {
            createMarker(
                initialCoordinates
            );

            setVenueStatus(
                '登録済みの位置を地図に表示しています。'
            );

            map.setView(
                [
                    initialCoordinates.lat,
                    initialCoordinates.lng
                ],
                18
            );
        } else {
            centerOnVenue();
        }

        /*
         * R10-5C ISSUE-06A:
         * 折りたたみ中はmapが0x0なので、
         * 実際に展開された後でも
         * Leafletの内部サイズを再計算する。
         */
        const mapCollapsible =
            mapDiv.closest(
                '.mw-collapsible'
            );

        if (
            mapCollapsible
        ) {
            $(
                mapCollapsible
            ).on(
                'afterExpand.mw-collapsible',
                function () {
                    setTimeout(
                        function () {
                            map.invalidateSize();
                        },
                        0
                    );
                }
            );
        }


        /*
         * 初期状態ですでに表示されている
         * ケースの既存挙動も維持。
         */
        setTimeout(
            function () {
                map.invalidateSize();
            },
            100
        );

        console.log(
            '臨時設備位置地図ピン入力を初期化しました。'
        );
    });
});

/* === R13-01 Venue地域かな検索 START === */
/*
 * Form:Venue 地域欄:
 * 漢字検索は Page Forms 標準。
 * ひらがな・カタカナ入力時は Areas.kana も検索する。
 *
 * ResourceLoader互換:
 * ?? / ?. / async / await / arrow function は使用しない。
 */
( function () {
    'use strict';

    var INSTALLED_ATTR =
        'data-r13-venue-kana-autocomplete';

    function toHiragana( value ) {
        return String( value || '' )
            .normalize( 'NFKC' )
            .replace(
                /[ァ-ヶ]/g,
                function ( ch ) {
                    return String.fromCharCode(
                        ch.charCodeAt( 0 ) - 0x60
                    );
                }
            )
            .replace( /\s+/g, '' )
            .trim();
    }

    function isKana( value ) {
        return /^[ぁ-ゖー]+$/.test( value );
    }

    function firstValue( obj, keys ) {
        var i;
        var key;
        var value;

        for ( i = 0; i < keys.length; i++ ) {
            key = keys[i];
            value = obj[key];

            if (
                typeof value !== 'undefined' &&
                value !== null &&
                value !== ''
            ) {
                return value;
            }
        }

        return '';
    }

    function normalizeRow( raw ) {
        return {
            areaId: firstValue(
                raw,
                [ 'area_id', 'area id', 'areaId' ]
            ),
            name: firstValue(
                raw,
                [ 'name', 'Name' ]
            ),
            kana: firstValue(
                raw,
                [ 'kana', 'Kana' ]
            ),
            areaType: firstValue(
                raw,
                [ 'area_type', 'area type', 'areaType' ]
            )
        };
    }

    function areaTypeLabel( type ) {
        var labels = {
            prefecture: '都道府県',
            city: '市',
            special_ward: '特別区',
            ward: '行政区',
            town: '町',
            village: '村'
        };

        return labels[type] || type || '';
    }

    function findVenueAreaInput() {
        var inputs =
            document.querySelectorAll(
                'input[role="combobox"]'
            );

        var i;
        var input;
        var span;
        var hidden;

        for ( i = 0; i < inputs.length; i++ ) {
            input = inputs[i];

            if (
                input.getAttribute(
                    'autocompletesettings'
                ) !== 'Areas|name'
            ) {
                continue;
            }

            span =
                input.closest(
                    '.comboboxSpan'
                );

            if ( !span ) {
                continue;
            }

            hidden =
                span.querySelector(
                    'input[type="hidden"]' +
                    '[name="Venue[area_id]"]'
                );

            if ( hidden ) {
                return {
                    input: input,
                    hidden: hidden
                };
            }
        }

        return null;
    }

    function install() {
        var pair =
            findVenueAreaInput();

        if ( !pair ) {
            return false;
        }

        var input =
            pair.input;

        var hidden =
            pair.hidden;

        if (
            input.getAttribute(
                INSTALLED_ATTR
            ) === '1'
        ) {
            return true;
        }

        input.setAttribute(
            INSTALLED_ATTR,
            '1'
        );

        var api =
            new mw.Api();

        var box =
            document.createElement(
                'div'
            );

        box.className =
            'r13-venue-kana-results';

        box.style.position =
            'absolute';

        box.style.zIndex =
            '1000000';

        box.style.background =
            '#fff';

        box.style.border =
            '1px solid #a2a9b1';

        box.style.borderRadius =
            '2px';

        box.style.boxShadow =
            '0 2px 6px rgba(0,0,0,.2)';

        box.style.maxHeight =
            '300px';

        box.style.overflowY =
            'auto';

        box.style.display =
            'none';

        box.style.boxSizing =
            'border-box';

        document.body.appendChild(
            box
        );

        var timer = null;
        var composing = false;
        var requestSeq = 0;

        function positionBox() {
            var rect =
                input.getBoundingClientRect();

            box.style.left =
                (
                    window.scrollX +
                    rect.left
                ) + 'px';

            box.style.top =
                (
                    window.scrollY +
                    rect.bottom +
                    2
                ) + 'px';

            box.style.width =
                Math.max(
                    rect.width,
                    220
                ) + 'px';
        }

        function clearBox() {
            while (
                box.firstChild
            ) {
                box.removeChild(
                    box.firstChild
                );
            }
        }

        function hideBox() {
            box.style.display =
                'none';

            clearBox();
        }

        function selectArea( row ) {
            requestSeq++;

            /*
             * Page Forms側ではnameを保持し、
             * フォーム送信時にarea_idへmapping。
             */
            input.value =
                row.name;

            hidden.value =
                row.name;

            input.setAttribute(
                'data-value',
                row.name
            );

            input.setAttribute(
                'data-label',
                row.name
            );

            input.setAttribute(
                'data-string-type',
                'value'
            );

            input.title =
                row.name;

            hideBox();

            input.dispatchEvent(
                new Event(
                    'change',
                    {
                        bubbles: true
                    }
                )
            );

            /*
             * Page Formsのchange処理後も
             * nameを確定。
             */
            input.value =
                row.name;

            hidden.value =
                row.name;
        }

        function render(
            rows,
            query
        ) {
            var i;
            var row;
            var item;
            var name;
            var meta;

            clearBox();

            if (
                rows.length === 0
            ) {
                var empty =
                    document.createElement(
                        'div'
                    );

                empty.textContent =
                    '読み仮名に一致する地域がありません';

                empty.style.padding =
                    '8px 10px';

                empty.style.color =
                    '#54595d';

                box.appendChild(
                    empty
                );

                positionBox();

                box.style.display =
                    'block';

                return;
            }

            rows.sort(
                function ( a, b ) {
                    var ak =
                        toHiragana(
                            a.kana
                        );

                    var bk =
                        toHiragana(
                            b.kana
                        );

                    var ar =
                        ak.indexOf(
                            query
                        ) === 0
                            ? 0
                            : 1;

                    var br =
                        bk.indexOf(
                            query
                        ) === 0
                            ? 0
                            : 1;

                    if (
                        ar !== br
                    ) {
                        return ar - br;
                    }

                    if (
                        ak.length !==
                        bk.length
                    ) {
                        return (
                            ak.length -
                            bk.length
                        );
                    }

                    return a.name.localeCompare(
                        b.name,
                        'ja'
                    );
                }
            );

            rows =
                rows.slice(
                    0,
                    25
                );

            for (
                i = 0;
                i < rows.length;
                i++
            ) {
                row =
                    rows[i];

                item =
                    document.createElement(
                        'button'
                    );

                item.type =
                    'button';

                item.style.display =
                    'block';

                item.style.width =
                    '100%';

                item.style.border =
                    '0';

                item.style.borderBottom =
                    '1px solid #eaecf0';

                item.style.background =
                    '#fff';

                item.style.padding =
                    '7px 10px';

                item.style.textAlign =
                    'left';

                item.style.cursor =
                    'pointer';

                item.style.font =
                    'inherit';

                name =
                    document.createElement(
                        'div'
                    );

                name.textContent =
                    row.name;

                name.style.fontWeight =
                    '600';

                name.style.color =
                    '#202122';

                meta =
                    document.createElement(
                        'div'
                    );

                meta.textContent =
                    row.kana +
                    (
                        row.areaType
                            ? ' ・ ' +
                              areaTypeLabel(
                                  row.areaType
                              )
                            : ''
                    );

                meta.style.marginTop =
                    '2px';

                meta.style.fontSize =
                    '11px';

                meta.style.color =
                    '#72777d';

                item.appendChild(
                    name
                );

                item.appendChild(
                    meta
                );

                ( function (
                    button,
                    area
                ) {
                    button.addEventListener(
                        'mouseenter',
                        function () {
                            button.style.background =
                                '#eaecf0';
                        }
                    );

                    button.addEventListener(
                        'mouseleave',
                        function () {
                            button.style.background =
                                '#fff';
                        }
                    );

                    button.addEventListener(
                        'mousedown',
                        function ( event ) {
                            event.preventDefault();
                        }
                    );

                    button.addEventListener(
                        'click',
                        function () {
                            selectArea(
                                area
                            );
                        }
                    );
                }(
                    item,
                    row
                ) );

                box.appendChild(
                    item
                );
            }

            positionBox();

            box.style.display =
                'block';
        }

        function search() {
            var query =
                toHiragana(
                    input.value
                );

            /*
             * かな2文字以上だけ追加検索。
             * それ以外はPage Forms標準へ任せる。
             */
            if (
                query.length < 2 ||
                !isKana(
                    query
                )
            ) {
                requestSeq++;
                hideBox();
                return;
            }

            var seq =
                ++requestSeq;

            var escaped =
                query.replace(
                    /'/g,
                    "''"
                );

            api.get(
                {
                    action:
                        'cargoquery',
                    format:
                        'json',
                    tables:
                        'Areas',
                    fields:
                        'area_id,name,kana,area_type',
                    where:
                        "kana LIKE '%" +
                        escaped +
                        "%'",
                    limit:
                        200
                }
            )
                .done(
                    function (
                        result
                    ) {
                        var rawRows =
                            result.cargoquery ||
                            [];

                        var rows = [];
                        var seen = {};
                        var i;
                        var raw;
                        var row;
                        var key;

                        if (
                            seq !==
                            requestSeq
                        ) {
                            return;
                        }

                        for (
                            i = 0;
                            i <
                            rawRows.length;
                            i++
                        ) {
                            raw =
                                rawRows[i].title ||
                                rawRows[i];

                            row =
                                normalizeRow(
                                    raw
                                );

                            if (
                                !row.areaId ||
                                !row.name ||
                                !row.kana
                            ) {
                                continue;
                            }

                            key =
                                String(
                                    row.areaId
                                );

                            if (
                                seen[key]
                            ) {
                                continue;
                            }

                            seen[key] =
                                true;

                            rows.push(
                                row
                            );
                        }

                        render(
                            rows,
                            query
                        );
                    }
                )
                .fail(
                    function (
                        code,
                        details
                    ) {
                        if (
                            seq !==
                            requestSeq
                        ) {
                            return;
                        }

                        console.error(
                            '[R13-01 VenueKana]',
                            code,
                            details
                        );

                        hideBox();
                    }
                );
        }

        function schedule() {
            if (
                composing
            ) {
                return;
            }

            clearTimeout(
                timer
            );

            timer =
                setTimeout(
                    search,
                    180
                );
        }

        input.addEventListener(
            'compositionstart',
            function () {
                composing =
                    true;
            }
        );

        input.addEventListener(
            'compositionend',
            function () {
                composing =
                    false;

                schedule();
            }
        );

        input.addEventListener(
            'input',
            schedule
        );

        document.addEventListener(
            'mousedown',
            function (
                event
            ) {
                if (
                    event.target !==
                        input &&
                    !box.contains(
                        event.target
                    )
                ) {
                    hideBox();
                }
            },
            true
        );

        window.addEventListener(
            'resize',
            function () {
                if (
                    box.style.display !==
                    'none'
                ) {
                    positionBox();
                }
            }
        );

        window.addEventListener(
            'scroll',
            function () {
                if (
                    box.style.display !==
                    'none'
                ) {
                    positionBox();
                }
            },
            true
        );

        return true;
    }

    function start() {
        var attempts =
            0;

        var installTimer =
            window.setInterval(
                function () {
                    attempts++;

                    if (
                        install() ||
                        attempts >= 40
                    ) {
                        window.clearInterval(
                            installTimer
                        );
                    }
                },
                250
            );
    }

    function ready() {
        if (
            document.readyState ===
            'loading'
        ) {
            document.addEventListener(
                'DOMContentLoaded',
                start,
                {
                    once: true
                }
            );
        } else {
            start();
        }
    }

    mw.loader.using(
        'mediawiki.api',
        ready,
        function ( error ) {
            console.error(
                '[R13-01 VenueKana load]',
                error
            );
        }
    );

}() );
/* === R13-01 Venue地域かな検索 END === */

/* === R14-02 Festival combobox hidden change bridge START === */
$(function () {
    function bindFestivalCombobox(fieldName) {
        var hidden =
            document.querySelector(
                'input[type="hidden"][name="' +
                    fieldName +
                    '"]'
            );

        if (!hidden) {
            return false;
        }

        if (
            hidden.getAttribute(
                'data-r14-festival-combo-bridge'
            ) === '1'
        ) {
            return true;
        }

        var span =
            $(hidden).closest(
                '.comboboxSpan'
            )[0];

        if (!span) {
            return false;
        }

        var visible =
            span.querySelector(
                'input:not([type="hidden"])'
            );

        if (!visible) {
            return false;
        }

        hidden.setAttribute(
            'data-r14-festival-combo-bridge',
            '1'
        );

        var lastValue =
            String(hidden.value || '');

        var timer = null;

        function dispatchHiddenChange() {
            var event =
                document.createEvent(
                    'HTMLEvents'
                );

            event.initEvent(
                'change',
                true,
                false
            );

            hidden.dispatchEvent(
                event
            );
        }

        function checkHiddenValue() {
            if (timer !== null) {
                window.clearTimeout(
                    timer
                );
            }

            timer =
                window.setTimeout(
                    function () {
                        timer = null;

                        var nextValue =
                            String(
                                hidden.value ||
                                ''
                            );

                        if (
                            nextValue ===
                            lastValue
                        ) {
                            return;
                        }

                        lastValue =
                            nextValue;

                        dispatchHiddenChange();
                    },
                    0
                );
        }

        visible.addEventListener(
            'input',
            checkHiddenValue
        );

        visible.addEventListener(
            'change',
            checkHiddenValue
        );

        visible.addEventListener(
            'blur',
            checkHiddenValue
        );

        span.addEventListener(
            'mouseup',
            checkHiddenValue
        );

        span.addEventListener(
            'keyup',
            checkHiddenValue
        );

        return true;
    }

    function retryBindFestivalCombobox(
        fieldName
    ) {
        var rawCombobox =
            document.querySelector(
                'select.pfComboBox[name="' +
                    fieldName +
                    '"]'
            );

        var hidden =
            document.querySelector(
                'input[type="hidden"][name="' +
                    fieldName +
                    '"]'
            );

        /*
         * Do not start retry timers on unrelated pages
         * or on fields that are still ordinary dropdowns.
         */
        if (!rawCombobox && !hidden) {
            return;
        }

        var attempts = 0;
        var maxAttempts = 50;

        function tryBind() {
            if (
                bindFestivalCombobox(
                    fieldName
                )
            ) {
                return;
            }

            attempts += 1;

            if (attempts >= maxAttempts) {
                console.warn(
                    '[R14-02] Festival combobox ' +
                    'bridge initialization timed out.',
                    fieldName
                );
                return;
            }

            window.setTimeout(
                tryBind,
                100
            );
        }

        tryBind();
    }

    function setupFestivalComboboxBridges() {
        retryBindFestivalCombobox(
            'FestivalStallPlacement[festival_id]'
        );

        retryBindFestivalCombobox(
            'FestivalTemporaryFacility[festival_id]'
        );
    }

    setupFestivalComboboxBridges();

    mw.hook(
        'pf.formSetupAfter'
    ).add(
        setupFestivalComboboxBridges
    );
});
/* === R14-02 Festival combobox hidden change bridge END === */
/* === R16 Entity duplicate candidate checker START === */
/*
 * Festival / Venue / Stall の名称入力時に、
 * Cargoの既存レコードから重複候補を表示する。
 *
 * 強い候補がある場合は、候補確認チェックを行うまで
 * wpSaveだけを停止する。プレビューと差分確認は利用可能。
 */
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';

    var INSTALLED_ATTR =
        'data-r16-entity-duplicate';

    var configs = [
        {
            template: 'Stall',
            selector: '[name="Stall[name]"]',
            table: 'Stalls',
            fields:
                '_pageName=page_name,' +
                'name=name,' +
                'category=detail',
            entityLabel: '屋台',
            detailLabel: 'カテゴリ',
            hasKana: false
        },
        {
            template: 'Venue',
            selector: '[name="Venue[name]"]',
            table: 'Venues',
            fields:
                '_pageName=page_name,' +
                'name=name,' +
                'kana=kana,' +
                'address=detail',
            entityLabel: '会場',
            detailLabel: '住所',
            hasKana: true
        },
        {
            template: 'Festival',
            selector: '[name="Festival[name]"]',
            table: 'Festivals',
            fields:
                '_pageName=page_name,' +
                'name=name,' +
                'kana=kana,' +
                'organizer=detail',
            entityLabel: '祭り',
            detailLabel: '主催',
            hasKana: true
        }
    ];

    function normalizeSearch(value) {
        return String(value || '')
            .normalize('NFKC')
            .replace(
                /[ァ-ヶ]/g,
                function (character) {
                    return String.fromCharCode(
                        character.charCodeAt(0) -
                        0x60
                    );
                }
            )
            .toLowerCase()
            .replace(
                /[\s\u3000・・//()()[\]【】「」『』\-‐‑‒–—―]+/g,
                ''
            );
    }

    function normalizePageName(value) {
        return String(value || '')
            .normalize('NFKC')
            .replace(/_/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function levenshtein(left, right) {
        var previous = [];
        var current;
        var i;
        var j;
        var cost;

        for (j = 0; j <= right.length; j++) {
            previous[j] = j;
        }

        for (i = 1; i <= left.length; i++) {
            current = [i];

            for (j = 1; j <= right.length; j++) {
                cost = (
                    left.charAt(i - 1) ===
                    right.charAt(j - 1)
                ) ? 0 : 1;

                current[j] = Math.min(
                    current[j - 1] + 1,
                    previous[j] + 1,
                    previous[j - 1] + cost
                );
            }

            previous = current;
        }

        return previous[right.length];
    }

    function bigrams(value) {
        var result = [];
        var i;

        if (value.length < 2) {
            return value ? [value] : [];
        }

        for (i = 0; i < value.length - 1; i++) {
            result.push(
                value.slice(i, i + 2)
            );
        }

        return result;
    }

    function dice(left, right) {
        var leftParts = bigrams(left);
        var rightParts = bigrams(right);
        var used = {};
        var common = 0;
        var i;
        var j;

        if (
            leftParts.length === 0 ||
            rightParts.length === 0
        ) {
            return 0;
        }

        for (i = 0; i < leftParts.length; i++) {
            for (
                j = 0;
                j < rightParts.length;
                j++
            ) {
                if (
                    !used[j] &&
                    leftParts[i] === rightParts[j]
                ) {
                    used[j] = true;
                    common++;
                    break;
                }
            }
        }

        return (
            2 * common /
            (
                leftParts.length +
                rightParts.length
            )
        );
    }

    function compare(query, candidate) {
        var ratio;
        var distance;
        var editScore;
        var diceScore;

        if (!query || !candidate) {
            return {
                score: 0,
                reason: ''
            };
        }

        if (query === candidate) {
            return {
                score: 1,
                reason: '完全一致'
            };
        }

        /*
         * 入力より短い一般語は低く評価する。
         * 例:「やきそば」に対する「そば」。
         */
        if (query.indexOf(candidate) !== -1) {
            ratio =
                candidate.length / query.length;

            return {
                score:
                    0.45 + 0.25 * ratio,
                reason: '短い名称を含む'
            };
        }

        if (candidate.indexOf(query) !== -1) {
            ratio =
                query.length / candidate.length;

            return {
                score:
                    0.65 + 0.25 * ratio,
                reason: '名称の一部が一致'
            };
        }

        distance = levenshtein(
            query,
            candidate
        );

        editScore = 1 - (
            distance /
            Math.max(
                query.length,
                candidate.length
            )
        );

        diceScore = dice(
            query,
            candidate
        );

        if (editScore >= diceScore) {
            return {
                score: Math.max(
                    0,
                    editScore
                ),
                reason:
                    '表記が近い(差' +
                    distance +
                    '文字)'
            };
        }

        return {
            score: diceScore,
            reason: '共通する文字列あり'
        };
    }

    function findConfig(root) {
        var i;
        var input;

        for (i = 0; i < configs.length; i++) {
            input = root.querySelector(
                configs[i].selector
            );

            if (input) {
                return {
                    config: configs[i],
                    input: input
                };
            }
        }

        return null;
    }

    function currentTarget(config) {
        var pageName = String(
            mw.config.get('wgPageName') || ''
        ).replace(/_/g, ' ');

        var parts = pageName.split('/');
        var i;

        for (i = 0; i < parts.length; i++) {
            if (parts[i] === config.template) {
                return parts.slice(i + 1).join('/');
            }
        }

        return '';
    }

    function createTextElement(tag, className, text) {
        var element =
            document.createElement(tag);

        if (className) {
            element.className = className;
        }

        element.textContent = text;

        return element;
    }

    function setupDuplicateChecker() {
        var root =
            document.getElementById('pfForm');

        if (!root) {
            return false;
        }

        if (
            root.getAttribute(
                INSTALLED_ATTR
            ) === '1'
        ) {
            return true;
        }

        var found = findConfig(root);

        if (!found) {
            return false;
        }

        root.setAttribute(
            INSTALLED_ATTR,
            '1'
        );

        var config = found.config;
        var input = found.input;
        var target = normalizePageName(
            currentTarget(config)
        );

        var panel =
            document.createElement('div');

        panel.id =
            'r16-entity-duplicate-panel';

        panel.className =
            'stall-duplicate-warning';

        panel.setAttribute(
            'role',
            'status'
        );

        var inputCell =
            input.closest('td') ||
            input.parentNode;

        inputCell.appendChild(panel);

        var saveButton =
            root.querySelector(
                '[name="wpSave"]'
            );

        var saveWarning =
            document.createElement('div');

        saveWarning.id =
            'r16-entity-duplicate-save-warning';

        saveWarning.className =
            'stall-duplicate-warning';

        saveWarning.setAttribute(
            'role',
            'alert'
        );

        saveWarning.hidden = true;

        var saveMessage =
            createTextElement(
                'strong',
                'stall-duplicate-warning-title',
                ''
            );

        var confirmationLabel =
            document.createElement('label');

        var confirmation =
            document.createElement('input');

        confirmation.type = 'checkbox';
        confirmation.value = '1';

        confirmationLabel.appendChild(
            confirmation
        );

        confirmationLabel.appendChild(
            document.createTextNode(
                ' 候補を確認し、別の' +
                config.entityLabel +
                'として登録します。'
            )
        );

        saveWarning.appendChild(saveMessage);
        saveWarning.appendChild(
            confirmationLabel
        );

        if (saveButton) {
            var saveAnchor =
                saveButton.closest(
                    '.oo-ui-widget'
                ) || saveButton;

            if (saveAnchor.parentNode) {
                saveAnchor.parentNode.insertBefore(
                    saveWarning,
                    saveAnchor
                );
            } else {
                root.appendChild(saveWarning);
            }
        } else {
            root.appendChild(saveWarning);
        }

        var api = new mw.Api();
        var rows = [];
        var timer = null;
        var composing = false;
        var ready = false;
        var failed = false;
        var strongCandidates = [];
        var lastQuery = '';

        function setStrongTone(strong) {
            panel.style.borderColor = strong
                ? '#b32424'
                : '';

            panel.style.borderLeftColor = strong
                ? '#b32424'
                : '';

            panel.style.background = strong
                ? '#fee7e6'
                : '';
        }

        function showSimple(text) {
            panel.replaceChildren(
                createTextElement(
                    'span',
                    '',
                    text
                )
            );

            setStrongTone(false);
            strongCandidates = [];
            saveWarning.hidden = true;
        }

        function render() {
            var query =
                normalizeSearch(input.value);

            var ranked = [];
            var i;
            var row;
            var nameMatch;
            var kanaMatch;
            var selectedMatch;
            var heading;
            var description;
            var list;

            if (query !== lastQuery) {
                confirmation.checked = false;
                lastQuery = query;
            }

            if (query.length < 2) {
                showSimple(
                    '2文字以上入力すると、' +
                    '登録済みの' +
                    config.entityLabel +
                    '候補を表示します。'
                );
                return;
            }

            if (!ready) {
                if (failed) {
                    showSimple(
                        '既存候補を取得できませんでした。' +
                        '保存前に一覧ページもご確認ください。'
                    );
                } else {
                    showSimple(
                        '登録済み候補を確認しています…'
                    );
                }

                return;
            }

            for (i = 0; i < rows.length; i++) {
                row = rows[i];

                if (
                    target &&
                    normalizePageName(
                        row.pageName
                    ) === target
                ) {
                    continue;
                }

                nameMatch = compare(
                    query,
                    normalizeSearch(row.name)
                );

                kanaMatch = config.hasKana
                    ? compare(
                        query,
                        normalizeSearch(row.kana)
                    )
                    : {
                        score: 0,
                        reason: ''
                    };

                if (
                    kanaMatch.score >
                    nameMatch.score
                ) {
                    selectedMatch = {
                        score: kanaMatch.score,
                        reason:
                            'よみ:' +
                            kanaMatch.reason
                    };
                } else {
                    selectedMatch = {
                        score: nameMatch.score,
                        reason:
                            '名称:' +
                            nameMatch.reason
                    };
                }

                if (selectedMatch.score < 0.35) {
                    continue;
                }

                ranked.push({
                    pageName: row.pageName,
                    name: row.name,
                    kana: row.kana,
                    detail: row.detail,
                    score: selectedMatch.score,
                    reason: selectedMatch.reason
                });
            }

            ranked.sort(function (left, right) {
                if (right.score !== left.score) {
                    return right.score - left.score;
                }

                return left.name.localeCompare(
                    right.name,
                    'ja'
                );
            });

            ranked = ranked.slice(0, 5);

            strongCandidates = ranked.filter(
                function (candidate) {
                    return candidate.score >= 0.72;
                }
            );

            panel.replaceChildren();

            if (ranked.length === 0) {
                showSimple(
                    '似ている登録済み候補は' +
                    '見つかりませんでした。'
                );
                return;
            }

            heading = createTextElement(
                'strong',
                'stall-duplicate-warning-title',
                strongCandidates.length
                    ? '重複の可能性が高い候補があります'
                    : '似ている登録済み候補'
            );

            description = createTextElement(
                'p',
                'stall-duplicate-warning-description',
                '既存ページを確認し、同じ対象なら' +
                '新規登録せず既存ページを編集してください。'
            );

            list = document.createElement('ul');
            list.className =
                'stall-duplicate-warning-list';

            ranked.forEach(function (candidate) {
                var item =
                    document.createElement('li');

                var link =
                    document.createElement('a');

                var details = [];

                link.href = mw.util.getUrl(
                    candidate.pageName
                );

                link.target = '_blank';
                link.rel = 'noopener';
                link.textContent = candidate.name;

                if (candidate.kana) {
                    details.push(
                        'よみ:' + candidate.kana
                    );
                }

                if (candidate.detail) {
                    details.push(
                        config.detailLabel +
                        ':' +
                        candidate.detail
                    );
                }

                details.push(candidate.reason);
                details.push(
                    '一致度' +
                    Math.round(
                        candidate.score * 100
                    ) +
                    '%'
                );

                item.appendChild(link);
                item.appendChild(
                    document.createTextNode(
                        ' — ' +
                        details.join('/')
                    )
                );

                list.appendChild(item);
            });

            panel.appendChild(heading);
            panel.appendChild(description);
            panel.appendChild(list);

            setStrongTone(
                strongCandidates.length > 0
            );

            if (strongCandidates.length > 0) {
                saveMessage.textContent =
                    '重複の可能性が高い候補が' +
                    strongCandidates.length +
                    '件あります。';

                saveWarning.hidden = false;
            } else {
                saveWarning.hidden = true;
            }

            console.log(
                'R16_ENTITY_DUPLICATE_RESULT',
                {
                    entity: config.template,
                    input: input.value,
                    target: target,
                    candidates: ranked,
                    strongCandidateCount:
                        strongCandidates.length
                }
            );
        }

        function scheduleRender() {
            if (composing) {
                return;
            }

            window.clearTimeout(timer);

            timer = window.setTimeout(
                render,
                250
            );
        }

        function shouldBlockSave() {
            return (
                normalizeSearch(input.value).length >= 2 &&
                !failed &&
                (
                    !ready ||
                    (
                        strongCandidates.length > 0 &&
                        !confirmation.checked
                    )
                )
            );
        }

        function blockSave(event) {
            if (!shouldBlockSave()) {
                return;
            }

            event.preventDefault();
            event.stopImmediatePropagation();

            if (!ready) {
                saveMessage.textContent =
                    '既存候補の確認が完了するまで' +
                    'お待ちください。';
            } else {
                saveMessage.textContent =
                    '既存候補を確認し、別データとして' +
                    '登録する場合はチェックしてください。';
            }

            saveWarning.hidden = false;

            saveWarning.scrollIntoView({
                behavior: 'smooth',
                block: 'center'
            });

            if (ready) {
                confirmation.focus();
            }
        }

        input.addEventListener(
            'compositionstart',
            function () {
                composing = true;
            }
        );

        input.addEventListener(
            'compositionend',
            function () {
                composing = false;
                scheduleRender();
            }
        );

        input.addEventListener(
            'input',
            scheduleRender
        );

        if (saveButton) {
            saveButton.addEventListener(
                'click',
                blockSave,
                true
            );
        }

        var formElement =
            input.closest('form');

        if (formElement) {
            formElement.addEventListener(
                'submit',
                function (event) {
                    var submitter =
                        event.submitter;

                    if (
                        submitter &&
                        submitter.name !== 'wpSave'
                    ) {
                        return;
                    }

                    blockSave(event);
                },
                true
            );
        }

        showSimple(
            '登録済み候補を読み込んでいます…'
        );

        api.get({
            action: 'cargoquery',
            format: 'json',
            tables: config.table,
            fields: config.fields,
            limit: 500
        }).then(function (data) {
            rows = (
                data.cargoquery || []
            ).map(function (item) {
                var value = item.title || {};

                return {
                    pageName:
                        value.page_name || '',
                    name:
                        value.name || '',
                    kana:
                        value.kana || '',
                    detail:
                        value.detail || ''
                };
            }).filter(function (row) {
                return (
                    row.pageName &&
                    row.name
                );
            });

            ready = true;
            failed = false;
            render();

            console.log(
                'R16_ENTITY_DUPLICATE_READY',
                {
                    entity: config.template,
                    loadedRows: rows.length
                }
            );
        }).catch(function (error) {
            ready = false;
            failed = true;
            render();

            console.error(
                '重複候補の取得に失敗しました。',
                error
            );
        });

        return true;
    }

    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            setupDuplicateChecker
        );
    } else {
        setupDuplicateChecker();
    }

    mw.hook('pf.formSetupAfter').add(
        setupDuplicateChecker
    );

    mw.hook('wikipage.content').add(
        setupDuplicateChecker
    );
});
/* === R16 Entity duplicate candidate checker END === */
/* === R16 Venue/Festival new-form name autofill START === */
/*
 * PageFormsで指定した新規ページ名を、
 * 空の会場名・祭り名へ初期値として反映する。
 *
 * 既に値がある場合は上書きしない。
 * 祭りページ名に全角の区切り「|」がある場合は、
 * 区切りより前だけを正式名称候補として使用する。
 */
(function () {
    'use strict';

    var installAttribute =
        'data-r16-name-prefill';

    var configs = [
        {
            entity: 'Venue',
            selector: '[name="Venue[name]"]',
            transform: function (targetName) {
                return targetName;
            }
        },
        {
            entity: 'Festival',
            selector: '[name="Festival[name]"]',
            transform: function (targetName) {
                return targetName
                    .split('|')[0]
                    .trim();
            }
        }
    ];

    function dispatchValueEvents(input) {
        input.dispatchEvent(
            new Event(
                'input',
                {
                    bubbles: true
                }
            )
        );

        input.dispatchEvent(
            new Event(
                'change',
                {
                    bubbles: true
                }
            )
        );
    }

    function setupNamePrefill() {
        var canonicalSpecial =
            mw.config.get(
                'wgCanonicalSpecialPageName'
            );

        var targetName =
            String(
                mw.config.get(
                    'wgPageFormsTargetName'
                ) || ''
            ).trim();

        var form =
            document.getElementById('pfForm');

        var i;
        var config;
        var input;
        var value;

        if (
            canonicalSpecial !== 'FormEdit' ||
            targetName === '' ||
            targetName === 'Dummy title' ||
            !form
        ) {
            return false;
        }

        if (
            form.getAttribute(
                installAttribute
            ) === '1'
        ) {
            return true;
        }

        for (i = 0; i < configs.length; i++) {
            config = configs[i];
            input = form.querySelector(
                config.selector
            );

            if (!input) {
                continue;
            }

            form.setAttribute(
                installAttribute,
                '1'
            );

            if (
                String(input.value || '')
                    .trim() !== ''
            ) {
                return true;
            }

            value =
                config.transform(targetName);

            if (value === '') {
                return true;
            }

            input.value = value;
            dispatchValueEvents(input);

            console.info(
                'R16_NAME_PREFILL',
                {
                    entity: config.entity,
                    targetName: targetName,
                    value: value
                }
            );

            return true;
        }

        return false;
    }

    function installNamePrefill() {
        if (setupNamePrefill()) {
            return;
        }

        window.setTimeout(
            setupNamePrefill,
            0
        );
    }

    if (
        document.readyState === 'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            installNamePrefill,
            {
                once: true
            }
        );
    } else {
        installNamePrefill();
    }

    if (mw.hook) {
        mw.hook(
            'pf.formSetupAfter'
        ).add(
            installNamePrefill
        );

        mw.hook(
            'wikipage.content'
        ).add(
            installNamePrefill
        );
    }
}());
/* === R16 Venue/Festival new-form name autofill END === */