編集の要約なし
安全な画像アップロードでファイル名の警告理由を表示
 
(3人の利用者による、間の36版が非表示)
1,867行目: 1,867行目:
     const placementIds =
     const placementIds =
         cards
         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(
             .map(
                 function ( card ) {
                 function ( card ) {
1,914行目: 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,042行目: 2,119行目:
         festivalMapMarkerIndexReady
         festivalMapMarkerIndexReady
     ) {
     ) {
        scheduleFestivalMapInitialViewport();
        return true;
    }
    /*
    * 座標付きplacementが0件なら
    * marker indexは0件で正常完了
    */
    if (
        mapPlacementIds.length === 0
    ) {
        festivalMapMarkerIndexReady =
            true;
         return true;
         return true;
     }
     }
2,057行目: 2,151行目:
     const expectedIds =
     const expectedIds =
         new Set(
         new Set(
             placementIds.map(
             mapPlacementIds.map(
                 String
                 String
             )
             )
2,173行目: 2,267行目:
             }
             }
         );
         );
    if (
        festivalMapMarkerIndexReady
    ) {
        scheduleFestivalMapInitialViewport();
    }




2,182行目: 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;


             const marker =
             return true;
                item.marker;
        }


            const markerLayer =
                item.markerLayer;


        festivalMapInitialViewportAttempts +=
            1;


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


        if (
            festivalMapInitialViewportAttempts >=
            MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
        ) {
            console.warn(
                '祭り屋台地図:markerのLeaflet map接続を確認できなかったため、初期viewport調整を中止しました。'
            );


             /*
             return false;
            * 表示対象
        }
            */
            if (
                visibleIds.has(
                    placementId
                )
            ) {


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


                    markerLayer.addLayer(
        festivalMapInitialViewportTimer =
                        marker
            window.setTimeout(
                    );
                function () {


                }
                    festivalMapInitialViewportTimer =
                        null;


                    scheduleFestivalMapInitialViewport();
                },
                100
            );


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


                if (
        return true;
                    isShown &&
    }
                    typeof markerLayer
                        .removeLayer ===
                        'function'
                ) {


                    markerLayer.removeLayer(
                        marker
                    );


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


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


function scheduleMapMarkerIndex() {
    festivalMapInitialViewportTimer =
        window.setTimeout(
            function () {


    if (
                festivalMapInitialViewportTimer =
        festivalMapMarkerIndexReady
                    null;
    ) {


        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );


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


                    festivalMapInitialViewportAttempts +=
                        1;


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


                    if (
                        festivalMapInitialViewportAttempts >=
                        MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
                    ) {
                        console.warn(
                            '祭り屋台地図:Maps初期化完了を確認できなかったため、初期viewport調整を中止しました。'
                        );


    function tryIndex() {
                        return;
                    }


        mapIndexTimer =
            null;


                    scheduleFestivalMapInitialViewport();


        if (
                    return;
            buildFestivalMapMarkerIndex()
                }
        ) {


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


            return;
                if (
        }
                    applyFestivalMapInitialViewport()
                ) {
                    festivalMapInitialViewportAttempts =
                        0;


                    return;
                }


        mapIndexAttempts +=
            1;


                festivalMapInitialViewportAttempts +=
                    1;


        if (
            mapIndexAttempts >=
            MAX_MAP_INDEX_ATTEMPTS
        ) {


            console.warn(
                if (
                '祭り屋台地図:placement_idとmarkerを対応付けできませんでした。'
                    festivalMapInitialViewportAttempts >=
            );
                    MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
                ) {
                    console.warn(
                        '祭り屋台地図:初期viewportを適用できなかったため、再試行を中止しました。'
                    );
 
                    return;
                }
 


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


        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );


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




     /*
     /*
     * 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,428行目: 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
                    ],


    const item =
                maxZoom:
        festivalMapMarkerIndex[
                    17,
            id
        ];


                animate:
                    false
            }
        );


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


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


         return false;
    festivalMapInitialViewportApplied =
         true;


     }
     return true;
}




    const marker =
/* =====================================
        item.marker;
* marker表示状態を変更
* ===================================== */


function applyMapMarkerFilter(
    visiblePlacementIds
) {


     /*
     const visibleIds =
    * 万一markerが非表示なら
         new Set(
    * 地図へ戻す
             visiblePlacementIds.map(
    */
                String
    if (
             )
        item.markerLayer &&
        typeof item.markerLayer
            .hasLayer ===
            'function' &&
         !item.markerLayer.hasLayer(
             marker
        ) &&
        typeof item.markerLayer
            .addLayer ===
             'function'
    ) {
 
        item.markerLayer.addLayer(
            marker
         );
         );


    }


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


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


        item.mapElement.scrollIntoView(
            {
                behavior:
                    'smooth',
                block:
                    'center'
            }
        );
    }
    /*
    * 少し待ってpopupを開く
    */
    window.setTimeout(
        function () {


             if (
             if (
                 typeof marker.openPopup ===
                 !item ||
                 'function'
                !item.marker ||
                 !item.markerLayer
             ) {
             ) {
 
                 return;
                 marker.openPopup();
 
             }
             }


        },
        300
    );


            const marker =
                item.marker;


    return true;
            const markerLayer =
                item.markerLayer;


}


/* =====================================
            /*
* 各屋台カード
            * markerが現在表示されているか
* 「地図で見る」ボタン生成
            */
* ===================================== */
            const isShown =
                typeof markerLayer
                    .hasLayer ===
                    'function'
                    ? markerLayer.hasLayer(
                        marker
                    )
                    : true;


function createMapViewButtons() {
    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;
            }




             const wrapper =
             /*
                document.createElement(
            * 非表示対象
                    'div'
            */
                );
            } else {


            wrapper.className =
                if (
                'festival-stall-map-view';
                    isShown &&
                    typeof markerLayer
                        .removeLayer ===
                        'function'
                ) {


                    markerLayer.removeLayer(
                        marker
                    );


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


             button.type =
             }
                'button';


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


            button.dataset.placementId =
}
                placementId;


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


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


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


    if (
        festivalMapMarkerIndexReady
    ) {


            wrapper.appendChild(
        applyMapMarkerFilter(
                button
            pendingVisiblePlacementIds
            );
        );
 
        return;
    }




            /*
    /*
            * 比較ボタンの近くへ配置
    * 二重タイマー防止
            */
    */
            const compareControl =
    if (
                card.querySelector(
        mapIndexTimer !== null
                    '.stall-compare-control'
    ) {
                );
        return;
    }




            if (
    function tryIndex() {
                compareControl &&
                compareControl.parentNode
            ) {


                compareControl.parentNode
        mapIndexTimer =
                    .insertBefore(
            null;
                        wrapper,
                        compareControl
                            .nextSibling
                    );


            } else {


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


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


            return;
         }
         }
    );


}


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


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


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


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




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




        const opened =
    tryIndex();
            openPlacementOnMap(
                placementId
            );


}


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


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


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


            button.disabled =
                true;


            button.textContent =
    if (
                '地図を準備中…';
        buildFestivalMapMarkerIndex()
    ) {


        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );


            window.setTimeout(
        return;
                function () {
    }


                    button.disabled =
                        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 &&
         typeof item.mapElement
            .scrollIntoView ===
             'function'
    ) {


    title.className =
        item.mapElement.scrollIntoView(
        'festival-stall-filter-label';
            {
                behavior:
                    'smooth',


    title.textContent =
                block:
        labelText;
                    'center'
 
             }
 
    const select =
        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 placementId =
* カテゴリ
                String(
*/
                    card.dataset
const categoryFilter =
                        .placementId ||
    createFilterSelect(
                    ''
        'カテゴリ',
                );
        'festival-stall-category-filter',
        'すべて'
    );




/*
            if (
* 会場
                !/^\d+$/.test(
*/
                    placementId
const venueFilter =
                ) ||
    createFilterSelect(
                placementId === '0'
        '会場',
            ) {
        'festival-stall-venue-filter',
                return;
        'すべて'
            }
    );




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


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


const venueSelect =
            wrapper.className =
    venueFilter.select;
                'festival-stall-map-view';




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


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


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


filterRow.appendChild(
            button.dataset.placementId =
    categoryFilter.wrapper
                placementId;
);


filterRow.appendChild(
            button.textContent =
    venueFilter.wrapper
                '地図で見る';
);


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


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


resetButton.type =
    'button';


resetButton.className =
            wrapper.appendChild(
    'festival-stall-search-reset';
                button
            );


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


resetButton.setAttribute(
            /*
    'aria-label',
            * 比較ボタンの近くへ配置
    '屋台の検索条件をすべてリセット'
            */
);
            const compareControl =
                card.querySelector(
                    '.stall-compare-control'
                );


resetButton.disabled =
    true;


    const count =
            if (
        document.createElement(
                compareControl &&
             'div'
                compareControl.parentNode
        );
             ) {


    count.className =
                compareControl.parentNode
        'festival-stall-search-count';
                    .insertBefore(
                        wrapper,
                        compareControl
                            .nextSibling
                    );
 
            } else {
 
                /*
                * 比較ボタンが見つからない場合は
                * カード末尾
                */
                card.appendChild(
                    wrapper
                );


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


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


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


noResults.textContent =
/* =====================================
    '条件に一致する屋台はありません。検索条件を変更してください。';
* 「地図で見る」クリック
* ===================================== */


noResults.hidden =
document.addEventListener(
     true;
     'click',
    function ( event ) {


noResults.setAttribute(
        const button =
    'role',
            event.target.closest(
    'status'
                '.festival-stall-map-view-button'
);
            );


    label.appendChild(
        input
    );


searchBox.appendChild(
        if (
    label
            !button
);
        ) {
            return;
        }


searchBox.appendChild(
    filterRow
);


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


/*
 
* リセット
        const opened =
*/
            openPlacementOnMap(
searchBox.appendChild(
                placementId
    resetButton
            );
);




searchBox.appendChild(
        /*
    count
        * Maps初期化前だった場合
);
        */
        if (
            !opened
        ) {


searchBox.appendChild(
            scheduleMapMarkerIndex();
    noResults
);




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


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


if (
    searchAnchor
) {


    searchAnchor.appendChild(
            window.setTimeout(
        searchBox
                function () {
    );


} else {
                    button.disabled =
                        false;


    /*
                    button.textContent =
    * 古いテンプレート等への
                        '地図で見る';
    * フォールバック
    */
    cards[
        0
    ].parentNode.insertBefore(
        searchBox,
        cards[
            0
        ]
    );


}


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


const searchIndex = {};
                },
                500
            );


        }


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


const venueOptions =
    new Map();


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


cards.forEach(
    const searchBox =
    function ( card ) {
        document.createElement(
            'div'
        );


        const placementId =
    searchBox.className =
            String(
        'festival-stall-search';
                card.dataset
                    .placementId ||
                ''
            );




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


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


        const venueName =
    label.textContent =
            String(
        '屋台を検索';
                card.dataset
                    .venueName ||
                ''
            ).trim();




        const normalizedCategory =
    const input =
            normalizeSearchText(
        document.createElement(
                category
            'input'
            );
        );


    input.type =
        'search';


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


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


         /*
    input.setAttribute(
        * placementごとの検索情報
        'autocomplete',
        */
         'off'
         searchIndex[
    );
            placementId
 
        ] = {
    input.setAttribute(
         'aria-label',
        '屋台名または商品名で検索'
    );


            text:
/* =====================================
                normalizeSearchText(
* フィルターselect
                    card.textContent
* ===================================== */
                ),


            category:
function createFilterSelect(
                normalizedCategory,
    labelText,
    className,
    allText
) {


             venueName:
    const wrapper =
                normalizedVenue
        document.createElement(
             'label'
        );


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




        /*
    const title =
        * カテゴリselect候補
         document.createElement(
        */
             'span'
         if (
         );
            normalizedCategory &&
            !categoryOptions.has(
                normalizedCategory
             )
         ) {


            categoryOptions.set(
    title.className =
                normalizedCategory,
        'festival-stall-filter-label';
                category
            );


         }
    title.textContent =
         labelText;




        /*
    const select =
        * 会場select候補
         document.createElement(
        */
             'select'
         if (
         );
            normalizedVenue &&
            !venueOptions.has(
                normalizedVenue
             )
         ) {


            venueOptions.set(
    select.className =
                normalizedVenue,
        'festival-stall-filter-select ' +
                venueName
        className;
            );


        }


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


/* =====================================
    allOption.value =
* select option生成
        '';
* ===================================== */


function fillFilterOptions(
     allOption.textContent =
    select,
        allText;
     optionMap
) {


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


    select.appendChild(
        allOption
    );


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


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


         }
    wrapper.appendChild(
         select
     );
     );




     options.forEach(
     return {
         function ( optionData ) {
        wrapper:
            wrapper,
 
         select:
            select
    };
 
}


            const value =
                optionData[
                    0
                ];


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




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


            option.value =
const categorySelect =
                value;
    categoryFilter.select;


            option.textContent =
                label;


const venueSelect =
    venueFilter.select;


            select.appendChild(
                option
            );


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


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




fillFilterOptions(
filterRow.appendChild(
     categorySelect,
     categoryFilter.wrapper
    categoryOptions
);
);


 
filterRow.appendChild(
fillFilterOptions(
     venueFilter.wrapper
     venueSelect,
    venueOptions
);
);


    /* =====================================
/*
    * 件数表示
* 絞り込みリセット
    * ===================================== */
* ===================================== */


     function updateCount(
const resetButton =
         visible
     document.createElement(
     ) {
         'button'
     );


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


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


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


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


resetButton.disabled =
    true;


     /* =====================================
     const count =
    * 検索実行
        document.createElement(
    * ===================================== */
            'div'
        );


function applySearch() {
    count.className =
        'festival-stall-search-count';


    /*
/* =====================================
    * フリーワード
* 検索結果0件メッセージ
    */
* ===================================== */
    const keyword =
        normalizeSearchText(
            input.value
        );


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


     /*
noResults.className =
    * カテゴリ
     'festival-stall-search-empty';
    */
 
     const selectedCategory =
noResults.textContent =
        categorySelect.value;
     '条件に一致する屋台はありません。検索条件を変更してください。';


noResults.hidden =
    true;


     /*
noResults.setAttribute(
    * 会場
    'role',
    */
     'status'
     const selectedVenue =
);
         venueSelect.value;
 
     label.appendChild(
         input
    );


searchBox.appendChild(
    label
);


let visible =
searchBox.appendChild(
     0;
     filterRow
);




/*
/*
  * 地図に残すplacement_id
  * リセット
  */
  */
const visiblePlacementIds =
searchBox.appendChild(
     [];
     resetButton
);




cards.forEach(
searchBox.appendChild(
        function ( card ) {
    count
);


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




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


                    text:
                        '',


                    category:
if (
                        '',
    searchAnchor
) {


                    venueName:
    searchAnchor.appendChild(
                        ''
        searchBox
    );


                };
} else {


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


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


            const keywordMatched =
    /* =====================================
                !keyword ||
    * カードごとの検索文字列
                index.text.includes(
    *
                    keyword
    * 最初はカード本文だけ
                );
    * ===================================== */


const searchIndex = {};


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


            const categoryMatched =
/*
                !selectedCategory ||
* select候補
                index.category ===
*/
                    selectedCategory;
const categoryOptions =
    new Map();


const venueOptions =
    new Map();


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


            const venueMatched =
cards.forEach(
                !selectedVenue ||
    function ( card ) {
                index.venueName ===
                    selectedVenue;


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


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


            const matched =
        const category =
                 keywordMatched &&
            String(
                 categoryMatched &&
                 card.dataset
                venueMatched;
                    .category ||
                 ''
            ).trim();




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


    card.style.display =
        '';


    visible +=
        const normalizedCategory =
        1;
            normalizeSearchText(
                category
            );




    /*
        const normalizedVenue =
    * 地図にも残す
            normalizeSearchText(
    */
                venueName
    visiblePlacementIds.push(
            );
        placementId
    );


} else {


                card.style.display =
        /*
                    'none';
        * placementごとの検索情報
        */
        searchIndex[
            placementId
        ] = {


             }
             text:
                normalizeSearchText(
                    card.textContent
                ),


        }
            category:
    );
                normalizedCategory,


            venueName:
                normalizedVenue


updateCount(
        };
    visible
);




/*
        /*
* 0件メッセージ
        * カテゴリselect候補
*/
        */
noResults.hidden =
        if (
    visible !== 0;
            normalizedCategory &&
            !categoryOptions.has(
                normalizedCategory
            )
        ) {


            categoryOptions.set(
                normalizedCategory,
                category
            );


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




/*
        /*
* 地図を一覧と同期
        * 会場select候補
*/
        */
syncMapMarkers(
        if (
    visiblePlacementIds
            normalizedVenue &&
);
            !venueOptions.has(
                normalizedVenue
            )
        ) {


            venueOptions.set(
                normalizedVenue,
                venueName
            );


}
        }


 
     }
/*
* 各カードへ
* 地図で見るボタン
*/
createMapViewButtons();
 
/*
* 初期状態
*
* 最初は全placementを表示
*/
syncMapMarkers(
     placementIds
);
);


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


    input.addEventListener(
function fillFilterOptions(
        'input',
    select,
        applySearch
    optionMap
    );
) {


categorySelect.addEventListener(
    const options =
    'change',
        Array.from(
    applySearch
            optionMap.entries()
);
        );




venueSelect.addEventListener(
    /*
    'change',
    * 表示名で並び替え
    applySearch
    */
);
    options.sort(
        function ( a, b ) {


/* =====================================
            return a[
* 絞り込みをすべてリセット
                1
* ===================================== */
            ].localeCompare(
                b[
                    1
                ],
                'ja'
            );


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


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


    options.forEach(
        function ( optionData ) {


        /*
            const value =
        * カテゴリ
                optionData[
        */
                    0
        categorySelect.value =
                ];
            '';


            const label =
                optionData[
                    1
                ];


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


            const option =
                document.createElement(
                    'option'
                );
            option.value =
                value;
            option.textContent =
                label;
            select.appendChild(
                option
            );
        }
    );


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




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


     }
fillFilterOptions(
     venueSelect,
    venueOptions
);
);


     /* =====================================
     /* =====================================
     * Placement → Offering取得
     * 件数表示
     * ===================================== */
     * ===================================== */


     cargoQuery(
     function updateCount(
        visible
    ) {


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


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


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


     ).then(
     updateCount(
         function ( offerings ) {
         cards.length
    );




            const menuItemIds =
    /* =====================================
                [
    * 検索実行
                    ...new Set(
    * ===================================== */
                        offerings
                            .map(
                                function (
                                    offering
                                ) {


                                    return String(
function applySearch() {
                                        offering
                                            .menu_item_id ||
                                        ''
                                    );


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


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


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




            /*
    /*
            * メニューが1件も無い
    * 会場
            */
    */
            if (
    const selectedVenue =
                menuItemIds.length === 0
        venueSelect.value;
            ) {


                return {
                    offerings:
                        offerings,


                    menus:
let visible =
                        []
    0;
                };


            }


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


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


            return cargoQuery(
cards.forEach(
        function ( card ) {


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


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


                'menu_item_id IN (' +
            const index =
                 menuItemIds.join(
                 searchIndex[
                     ','
                     placementId
                ) +
                 ] || {
                 ')'


            ).then(
                    text:
                function ( menus ) {
                        '',


                     return {
                     category:
                        '',


                        offerings:
                    venueName:
                            offerings,
                        ''


                        menus:
                };
                            menus


                    };


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


        }
            const keywordMatched =
    ).then(
                !keyword ||
        function ( data ) {
                index.text.includes(
                    keyword
                );


            if (
                !data
            ) {
                return;
            }


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


             /* =================================
             const categoryMatched =
            * menu_item_id → 商品名
                !selectedCategory ||
            * ================================= */
                index.category ===
                    selectedCategory;


            const menuNameMap =
                {};


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


             data.menus.forEach(
             const venueMatched =
                function ( menu ) {
                !selectedVenue ||
                index.venueName ===
                    selectedVenue;


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


                }
            /* =============================
            );
            * AND条件
            * ============================= */


            const matched =
                keywordMatched &&
                categoryMatched &&
                venueMatched;


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


            const placementMenus =
if (
                {};
    matched
) {


    card.style.display =
        '';


            data.offerings.forEach(
    visible +=
                function ( offering ) {
        1;


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


                    const menuItemId =
    /*
                        String(
    * 地図にも残す
                            offering
    */
                                .menu_item_id ||
    visiblePlacementIds.push(
                            ''
        placementId
                        );
    );


} else {


                    const menuName =
                card.style.display =
                        menuNameMap[
                    'none';
                            menuItemId
                        ] || '';


            }


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




                    if (
updateCount(
                        !placementMenus[
    visible
                            placementId
);
                        ]
                    ) {


                        placementMenus[
                            placementId
                        ] = [];


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




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


                }
            );


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


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


            cards.forEach(
}
                function ( card ) {


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


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


                    const menuNames =
/*
                        placementMenus[
* 初期状態
                            placementId
*
                        ] || [];
* 最初は全placementを表示
*/
syncMapMarkers(
    placementIds
);




if (
    input.addEventListener(
    searchIndex[
        'input',
         placementId
         applySearch
     ]
     );
) {


    searchIndex[
categorySelect.addEventListener(
        placementId
    'change',
    ].text =
    applySearch
        normalizeSearchText(
);
            (
                searchIndex[
                    placementId
                ].text ||
                ''
            ) +
            ' ' +
            menuNames.join(
                ' '
            )
        );


}


                }
venueSelect.addEventListener(
            );
    'change',
    applySearch
);


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


            /*
resetButton.addEventListener(
            * 商品データ取得後、
    'click',
            * 入力済み検索を再判定
    function () {
            */
            applySearch();


         }
         /*
    ).catch(
        * フリーワード
        function ( error ) {
        */
        input.value =
            '';


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


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


} );


/* ========================================
        /*
* 屋台比較ページ
        * 会場
* placement_id 正式版
        */
* ======================================== */
        venueSelect.value =
            '';


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


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




    const compareRoot =
        /*
         document.getElementById(
        * 続けて検索しやすくする
            'stall-compare-page'
        */
        );
         input.focus();


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


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


     const STORAGE_KEY =
     cargoQuery(
        'matsuriWikiComparePlacements';


    const MIN_COMPARE = 2;
        'FestivalStallMenuOfferings',
    const MAX_COMPARE = 4;


    const api =
        'placement_id=placement_id,' +
         new mw.Api();
         'menu_item_id=menu_item_id',


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


     /* =====================================
     ).then(
    * localStorage
        function ( offerings ) {
    * ===================================== */


    function getPlacementIds() {


        const raw =
            const menuItemIds =
            mw.storage.get(
                [
                STORAGE_KEY
                    ...new Set(
            );
                        offerings
                            .map(
                                function (
                                    offering
                                ) {


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


        if ( !raw ) {
                                }
            return [];
                            )
        }
                            .filter(
                                function ( id ) {


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


        try {
                                }
 
                            )
            const ids =
                     )
                JSON.parse(
                 ];
                     raw
                 );




            /*
            * メニューが1件も無い
            */
             if (
             if (
                 !Array.isArray(
                 menuItemIds.length === 0
                    ids
                )
             ) {
             ) {
                return [];
            }


                return {
                    offerings:
                        offerings,


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


            }


        } catch ( e ) {


             return [];
            /* =================================
            * MenuItem名取得
            * ================================= */
 
             return cargoQuery(


        }
                'StallMenuItems',


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


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


    /* =====================================
            ).then(
    * Cargo
                function ( menus ) {
    * ===================================== */


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


        const params = {
                        offerings:
                            offerings,


            action: 'cargoquery',
                        menus:
                            menus


            tables: table,
                    };


             fields: fields,
                }
             );


            limit: limit || 100,
        }
    ).then(
        function ( data ) {


             format: 'json'
             if (
                !data
            ) {
                return;
            }


        };


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


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




        return api.get(
            data.menus.forEach(
            params
                function ( menu ) {
        ).then(
 
            function ( data ) {
                    menuNameMap[
                        String(
                            menu.menu_item_id
                        )
                    ] =
                        menu.menu_name ||
                        '';


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




                return data.cargoquery.map(
            /* =================================
                    function ( item ) {
            * placement_id → 商品名[]
            * ================================= */


                        return (
            const placementMenus =
                            item.title ||
                {};
                            item
                        );


                    }
                );


             }
             data.offerings.forEach(
        );
                function ( offering ) {


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


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


    function makeInClause( ids ) {


        return ids
                     const menuName =
            .map( String )
                         menuNameMap[
            .filter(
                            menuItemId
                function ( id ) {
                        ] || '';
                     return /^\d+$/.test(
                         id
                    );
                }
            )
            .join( ',' );


    }


                    if (
                        !menuName
                    ) {
                        return;
                    }


    function uniqueIds( values ) {


        return [
                    if (
            ...new Set(
                        !placementMenus[
                values
                            placementId
                     .map( String )
                        ]
                    .filter(
                     ) {
                        function ( id ) {


                             return (
                        placementMenus[
                                id &&
                             placementId
                                /^\d+$/.test(
                        ] = [];
                                    id
                                )
                            );


                        }
                    }
                    )
            )
        ];


    }


                    placementMenus[
                        placementId
                    ].push(
                        menuName
                    );


    function mapBy(
                }
        rows,
            );
        key
    ) {


        const result = {};


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


        rows.forEach(
            cards.forEach(
            function ( row ) {
                function ( card ) {


                if (
                    const placementId =
                    row[ key ] ===
                        String(
                    undefined
                            card.dataset
                ) {
                                .placementId ||
                    return;
                            ''
                }
                        );




                result[
                     const menuNames =
                     String(
                         placementMenus[
                         row[ key ]
                            placementId
                    )
                        ] || [];
                ] = row;


            }
        );


if (
    searchIndex[
        placementId
    ]
) {


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


    }
}


                }
            );


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


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


        if (
            value === undefined ||
            value === null ||
            value === ''
        ) {
            return '―';
         }
         }
    ).catch(
        function ( error ) {


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


     }
        }
     );


} );


function cleanNumber(
/* ========================================
     value
* 屋台比較ページ
) {
* placement_id 正式版
* ======================================== */
 
mw.loader.using( [
    'mediawiki.storage',
     'mediawiki.api',
    'mediawiki.util'
] ).then( function () {
 
    'use strict';


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


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


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


    if (
        Number.isInteger(
            number
        )
    ) {


        return String(
    const STORAGE_KEY =
            number
         'matsuriWikiComparePlacements';
         );


     }
     const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;


     return String(
     const api =
         Math.round(
         new mw.Api();
            number * 100
        ) / 100
    );


}


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


/* =====================================
    function getPlacementIds() {
* 比較計算用数値
* ===================================== */


function toFiniteNumber(
        const raw =
    value
            mw.storage.get(
) {
                STORAGE_KEY
            );


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


    const number =
         if ( !raw ) {
         Number(
             return [];
             value
         }
         );
 


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


    return number;
            const ids =
                JSON.parse(
                    raw
                );


}


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


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


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


        const open =
            placement.opening_time || '';


         const close =
         } catch ( e ) {
            placement.closing_time || '';


            return [];


         if (
         }
            open &&
            close
        ) {


            return (
    }
                open +
                '~' +
                close
            );


        }


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


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


            return (
        const params = {
                open +
                '~'
            );


        }
            action: 'cargoquery',


            tables: table,


        if ( close ) {
            fields: fields,


             return (
             limit: limit || 100,
                '~' +
                close
            );


        }
            format: 'json'


        };


        if (
            placement.hours_note
        ) {
            return placement.hours_note;


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




         return '未確認';
         return api.get(
            params
        ).then(
            function ( data ) {


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




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


        switch ( status ) {
                        return (
                            item.title ||
                            item
                        );


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


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


default:
     }
     return '位置未確認';


        }


     }
     function makeInClause( ids ) {


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


     function formatVerification(
     }
        status
    ) {


        switch ( status ) {


            case 'verified':
    function uniqueIds( values ) {
                return '確認済み';


             case 'partially_verified':
        return [
                 return '一部確認済み';
             ...new Set(
                 values
                    .map( String )
                    .filter(
                        function ( id ) {


            case 'outdated':
                            return (
                return '情報が古い';
                                id &&
                                /^\d+$/.test(
                                    id
                                )
                            );


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


     }
     }




     function formatAvailability(
     function mapBy(
         status
         rows,
        key
     ) {
     ) {


         switch ( status ) {
         const result = {};


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


             case 'unavailable':
        rows.forEach(
                return '販売なし';
             function ( row ) {


            default:
                if (
                 return '未確認';
                    row[ key ] ===
                    undefined
                 ) {
                    return;
                }


        }


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


            }
        );


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


function getUnitPrice(
        return result;
    offering
) {


     const price =
     }
        toFiniteNumber(
            offering.price
        );


    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


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


     if (
     function textOrDash(
         price === null ||
         value
        quantity === null ||
        quantity <= 0
     ) {
     ) {


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


     }
     }




    const unitPrice =
function cleanNumber(
        Math.round(
    value
            (
) {
                price /
                quantity
            ) *
            100
        ) /
        100;


    if (
        value === undefined ||
        value === null ||
        String( value ).trim() === ''
    ) {
        return '';
    }
    const number =
        Number(
            value
        );
    if (
        !Number.isFinite(
            number
        )
    ) {
        return '';
    }


     const unit =
     if (
         offering.serving_unit ||
         Number.isInteger(
         '単位';
            number
        )
    ) {
 
        return String(
            number
         );


    }


     return (
     return String(
         unitPrice +
         Math.round(
         '円/' +
            number * 100
        unit
         ) / 100
     );
     );


4,295行目: 4,617行目:


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


function getUnitPriceValue(
function toFiniteNumber(
     offering
     value
) {
) {
    const price =
        toFiniteNumber(
            offering.price
        );
    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


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


    const number =
        Number(
            value
        );


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


}
}


 
     function formatHours(
    /* =====================================
         placement
    * DOM
    * ===================================== */
 
     function createTextCell(
         tagName,
        text
     ) {
     ) {


         const cell =
         if ( !placement ) {
            document.createElement(
             return '―';
                tagName
        }
             );


        cell.textContent =
            text;


         return cell;
         const open =
            placement.opening_time || '';


    }
        const close =
            placement.closing_time || '';




    /* =====================================
        if (
    * メニュー
            open &&
    * ===================================== */
            close
        ) {


    function createMenuList(
            return (
        menus
                open +
    ) {
                '~' +
                close
            );


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


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


        if ( open ) {
            return (
                open +
                '~'
            );
        }


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


            container.textContent =
        if ( close ) {
                'メニュー未登録';


             return container;
             return (
                '~' +
                close
            );


         }
         }




         menus.forEach(
         if (
             function ( item ) {
             placement.hours_note
        ) {
 
            return placement.hours_note;


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


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


        return '未確認';


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


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


                name.textContent =
    function formatPositionStatus(
                    item.menuName ||
        status
                    '商品';
    ) {


        switch ( status ) {


const price =
case 'exact':
     document.createElement(
     return '正確な位置';
        'div'
    );


price.className =
case 'approximate':
     'stall-compare-menu-price';
     return 'おおよその位置';


default:
    return '位置未確認';


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


    }


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


     const badge =
     function formatVerification(
         document.createElement(
         status
            'span'
    ) {
         );
 
         switch ( status ) {


    badge.className =
            case 'verified':
        'stall-compare-best-badge ' +
                return '確認済み';
        'stall-compare-best-price';


    badge.textContent =
            case 'partially_verified':
        '最安価格';
                return '一部確認済み';


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


    price.appendChild(
            default:
        document.createTextNode(
                return '未確認';
            ' '
        )
    );


    price.appendChild(
         }
         badge
    );


}
    }




                const serving =
    function formatAvailability(
                    document.createElement(
        status
                        'div'
    ) {
                    );


        switch ( status ) {


                if (
            case 'available':
                    item.servingQuantity
                 return '販売あり';
                 ) {


                    serving.textContent =
            case 'unavailable':
                        '内容量:' +
                return '販売なし';
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );


                 } else {
            default:
                 return '未確認';


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


                }
    }




const unit =
/* =====================================
    document.createElement(
* 単位価格
        'div'
* 表示用
    );
* ===================================== */


unit.className =
function getUnitPrice(
     'stall-compare-menu-unit-price';
     offering
) {


    const price =
        toFiniteNumber(
            offering.price
        );


unit.textContent =
    const quantity =
    '1単位あたり:' +
        toFiniteNumber(
    item.unitPrice;
            offering.serving_quantity
        );




/*
    if (
* 最安単位価格
        price === null ||
*/
        quantity === null ||
if (
        quantity <= 0
     item.isLowestUnitPrice
     ) {
) {


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


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


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


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


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


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


}


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


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


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


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


                menu.appendChild(
function getUnitPriceValue(
                    name
    offering
                );
) {


                menu.appendChild(
    const price =
                    price
        toFiniteNumber(
                );
            offering.price
        );


                menu.appendChild(
    const quantity =
                    serving
        toFiniteNumber(
                );
            offering.serving_quantity
        );


                menu.appendChild(
                    unit
                );


                menu.appendChild(
    if (
                    availability
        price === null ||
                );
        quantity === null ||
        quantity <= 0
    ) {


        return null;


                container.appendChild(
    }
                    menu
                );


            }
        );


    return (
        price /
        quantity
    );


        return container;
}
 
    }




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


     function renderComparison(
     function createTextCell(
         compareData
         tagName,
        text
     ) {
     ) {


         compareRoot.innerHTML =
         const cell =
             '';
            document.createElement(
                tagName
             );


        cell.textContent =
            text;


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


        heading.textContent =
    }
            '屋台比較';




        compareRoot.appendChild(
    /* =====================================
            heading
    * メニュー
        );
    * ===================================== */


    function createMenuList(
        menus
    ) {


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


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




         const table =
         if (
             document.createElement(
            !menus ||
                'table'
             menus.length === 0
            );
        ) {


        table.className =
            container.textContent =
            'stall-compare-table';
                'メニュー未登録';


            return container;


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


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


         const headerRow =
         menus.forEach(
            document.createElement(
             function ( item ) {
                'tr'
             );


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


        headerRow.appendChild(
                menu.className =
            createTextCell(
                    'stall-compare-menu-item';
                'th',
                '比較項目'
            )
        );




        compareData.forEach(
                 const name =
            function ( data ) {
 
                 const th =
                     document.createElement(
                     document.createElement(
                         'th'
                         'strong'
                     );
                     );


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


                 if (
                 name.textContent =
                     data.stall &&
                     item.menuName ||
                     data.stall.page_name
                     '商品';
                ) {


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


                    link.href =
const price =
                        mw.util.getUrl(
    document.createElement(
                            data.stall
        'div'
                                .page_name
    );
                        );


                    link.textContent =
price.className =
                        data.stall
    'stall-compare-menu-price';
                            .stall_name ||
                        '屋台';




                    th.appendChild(
price.textContent =
                        link
    item.price
                    );
        ? item.price +
          '円'
        : '価格未確認';


                } else {


                    th.textContent =
/*
                        data.stall
* 最安価格
                            ? data.stall.stall_name
*/
                            : '屋台';
if (
    item.isLowestPrice
) {


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


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


                headerRow.appendChild(
    badge.textContent =
                    th
         '最安価格';
                );
 
            }
         );




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


        table.appendChild(
    price.appendChild(
            thead
        badge
        );
    );


}


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


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


        function addRow(
            label,
            getter
        ) {


            const tr =
                 if (
                 document.createElement(
                     item.servingQuantity
                     'tr'
                 ) {
                 );


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


            const labelCell =
                 } else {
                 createTextCell(
                    'th',
                    label
                );


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


 
                 }
            tr.appendChild(
                 labelCell
            );




            compareData.forEach(
const unit =
                function ( data ) {
    document.createElement(
        'div'
    );
 
unit.className =
    'stall-compare-menu-unit-price';


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


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




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


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


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


        /* =================================
    badge.textContent =
        * Placement情報
        '最安単位価格';
        * ================================= */


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


                return data.placement
    unit.appendChild(
                    ? data.placement.year +
        document.createTextNode(
                      ''
            ' '
                    : '―';
        )
    );


            }
    unit.appendChild(
         );
         badge
    );


}


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


                 return data.festival
                 const availability =
                     ? data.festival
                     document.createElement(
                         .festival_name
                         'div'
                     : '―';
                     );


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




        addRow(
                menu.appendChild(
            '会場',
                    name
            function ( data ) {
                );


                 return data.venue
                 menu.appendChild(
                     ? data.venue
                     price
                        .venue_name
                );
                    : '―';


            }
                menu.appendChild(
        );
                    serving
                );


                menu.appendChild(
                    unit
                );
                menu.appendChild(
                    availability
                );


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


                 return data.area
                 container.appendChild(
                     ? data.area
                     menu
                        .area_name
                );
                    : '―';


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




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


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


            }
        );


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


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


                return data.placement
        compareRoot.innerHTML =
                    ? data.placement
            '';
                        .location_note
                    : '';


            }
        );


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


         addRow(
         heading.textContent =
             '営業時間',
             '屋台比較';
            function ( data ) {


                return formatHours(
                    data.placement
                );


             }
        compareRoot.appendChild(
             heading
         );
         );




         addRow(
         const wrapper =
            '位置情報',
            document.createElement(
             function ( data ) {
                'div'
             );


                return data.placement
        wrapper.className =
                    ? formatPositionStatus(
            'stall-compare-table-wrapper';
                        data.placement
                            .position_status
                    )
                    : '';


            }
        );


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


         addRow(
         table.className =
             '確認状態',
             'stall-compare-table';
            function ( data ) {


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


            }
        /* ------------------------------
        );
        * thead
        * ------------------------------ */


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


        /* =================================
         const headerRow =
        * メニュー
        * ================================= */
 
         const menuRow =
             document.createElement(
             document.createElement(
                 'tr'
                 'tr'
4,896行目: 5,173行目:




         const menuLabel =
         headerRow.appendChild(
             createTextCell(
             createTextCell(
                 'th',
                 'th',
                 'メニュー'
                 '比較項目'
             );
             )
 
        menuLabel.scope =
            'row';
 
 
        menuRow.appendChild(
            menuLabel
         );
         );


4,914行目: 5,184行目:
             function ( data ) {
             function ( data ) {


                 const td =
                 const th =
                     document.createElement(
                     document.createElement(
                         'td'
                         '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 {


                td.appendChild(
                    th.textContent =
                    createMenuList(
                        data.stall
                        data.menus
                            ? data.stall.stall_name
                    )
                            : '屋台';
                 );
 
                 }




                 menuRow.appendChild(
                 headerRow.appendChild(
                     td
                     th
                 );
                 );


4,935行目: 5,234行目:




         tbody.appendChild(
         thead.appendChild(
             menuRow
             headerRow
         );
         );


         table.appendChild(
         table.appendChild(
             tbody
             thead
         );
         );


        wrapper.appendChild(
            table
        );


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


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


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


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


        compareRoot.appendChild(
            note
        );


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


            labelCell.scope =
                'row';


    function renderMessage(
        message
    ) {


        compareRoot.innerHTML =
            tr.appendChild(
             '';
                labelCell
             );




        const p =
             compareData.forEach(
             document.createElement(
                 function ( data ) {
                 'p'
            );


        p.className =
                    tr.appendChild(
            'stall-compare-page-message';
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );


        p.textContent =
                }
             message;
             );




        compareRoot.appendChild(
            tbody.appendChild(
             p
                tr
         );
             );
 
        }
 
 
         /* =================================
        * Placement情報
        * ================================= */


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


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


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


    const placementIds =
        getPlacementIds();


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


    if (
                return data.festival
        placementIds.length <
                    ? data.festival
        MIN_COMPARE
                        .festival_name
    ) {
                    : '―';


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


        return;


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


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


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


        'FestivalStallPlacements',


         'placement_id=placement_id,' +
         addRow(
        'stall_id=stall_id,' +
            '地域',
        'festival_id=festival_id,' +
            function ( data ) {
        'venue_id=venue_id,' +
 
        'year=year,' +
                return data.area
        'latitude=latitude,' +
                    ? data.area
        'longitude=longitude,' +
                        .area_name
        '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(
        addRow(
        function ( placements ) {
            'カテゴリ',
            function ( data ) {


            const stallIds =
                 return data.stall
                 uniqueIds(
                     ? data.stall
                     placements.map(
                         .category
                         function ( row ) {
                     : '―';
                            return row.stall_id;
                        }
                     )
                );


            }
        );


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


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


            const venueIds =
                 return data.placement
                 uniqueIds(
                     ? data.placement
                     placements.map(
                         .location_note
                         function ( row ) {
                     : '―';
                            return row.venue_id;
                        }
                     )
                );


            }
        );


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


                stallIds.length
        addRow(
                    ? cargoQuery(
            '営業時間',
            function ( data ) {


                        'Stalls',
                return formatHours(
                    data.placement
                );


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


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


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


                return data.placement
                    ? formatPositionStatus(
                        data.placement
                            .position_status
                     )
                     )
                     : Promise.resolve(
                     : '―';
                        []
                    ),


            }
        );


                festivalIds.length
                    ? cargoQuery(


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


                         'festival_id=festival_id,' +
                return data.placement
                        'name=festival_name,' +
                    ? formatVerification(
                        '_pageName=page_name',
                         data.placement
                            .verification_status
                    )
                    : '';


                        'festival_id IN (' +
            }
                        makeInClause(
        );
                            festivalIds
                        ) +
                        ')',
 
                        100


                    )
                    : Promise.resolve(
                        []
                    ),


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


                 venueIds.length
        const menuRow =
                    ? cargoQuery(
            document.createElement(
                 'tr'
            );


                        'Venues',


                        'venue_id=venue_id,' +
        const menuLabel =
                        'name=venue_name,' +
            createTextCell(
                        'area_id=area_id,' +
                'th',
                        '_pageName=page_name',
                'メニュー'
            );


                        'venue_id IN (' +
        menuLabel.scope =
                        makeInClause(
            'row';
                            venueIds
                        ) +
                        ')',


                        100


                    )
        menuRow.appendChild(
                    : Promise.resolve(
            menuLabel
                        []
        );
                    ),




                cargoQuery(
        compareData.forEach(
            function ( data ) {


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


                    '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 (' +
                td.appendChild(
                     makeInClause(
                     createMenuList(
                         placementIds
                         data.menus
                     ) +
                     )
                    ')',
                );


                    100


                 )
                menuRow.appendChild(
                    td
                 );


             ] ).then(
             }
                function ( results ) {
        );


                    return {


                        placements:
        tbody.appendChild(
                            placements,
            menuRow
        );


                        stalls:
                            results[ 0 ],


                        festivals:
        table.appendChild(
                            results[ 1 ],
            tbody
        );


                        venues:
        wrapper.appendChild(
                            results[ 2 ],
            table
        );


                        offerings:
        compareRoot.appendChild(
                            results[ 3 ]
            wrapper
        );


                    };


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


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


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


        compareRoot.appendChild(
            note
        );


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




            /*
    function renderMessage(
            * STEP 3
        message
            */
    ) {
            return Promise.all( [


                areaIds.length
        compareRoot.innerHTML =
                    ? cargoQuery(
            '';


                        'Areas',


                        'area_id=area_id,' +
        const p =
                        'name=area_name,' +
            document.createElement(
                        '_pageName=page_name',
                'p'
            );


                        'area_id IN (' +
        p.className =
                        makeInClause(
            'stall-compare-page-message';
                            areaIds
                        ) +
                        ')',


                        100
        p.textContent =
            message;


                    )
                    : Promise.resolve(
                        []
                    ),


        compareRoot.appendChild(
            p
        );


                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 (' +
    const placementIds =
                        makeInClause(
        getPlacementIds();
                            menuItemIds
                        ) +
                        ')',


                        100


                    )
    if (
                    : Promise.resolve(
        placementIds.length <
                        []
        MIN_COMPARE
                    )
    ) {


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


                    data.areas =
        return;
                        results[ 0 ];


                    data.menuItems =
    }
                        results[ 1 ];


                    return data;


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


         }
         'FestivalStallPlacements',
    ).then(
        function ( data ) {


            const placementMap =
        'placement_id=placement_id,' +
                mapBy(
        'stall_id=stall_id,' +
                    data.placements,
        'festival_id=festival_id,' +
                    'placement_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
        ) +
        ')',


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


    ).then(
        function ( placements ) {


             const festivalMap =
             const stallIds =
                 mapBy(
                 uniqueIds(
                     data.festivals,
                     placements.map(
                     'festival_id'
                        function ( row ) {
                            return row.stall_id;
                        }
                     )
                 );
                 );




             const venueMap =
             const festivalIds =
                 mapBy(
                 uniqueIds(
                     data.venues,
                     placements.map(
                     'venue_id'
                        function ( row ) {
                            return row.festival_id;
                        }
                     )
                 );
                 );




             const areaMap =
             const venueIds =
                 mapBy(
                 uniqueIds(
                     data.areas,
                     placements.map(
                    'area_id'
                        function ( row ) {
                );
                            return row.venue_id;
 
                        }
 
                     )
            const menuMap =
                mapBy(
                     data.menuItems,
                    'menu_item_id'
                 );
                 );




             /*
             /*
             * Offering
             * STEP 2
            * placement単位
             */
             */
             const offeringsByPlacement =
             return Promise.all( [
                {};


                stallIds.length
                    ? cargoQuery(


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


                         return (
                         'stall_id=stall_id,' +
                            Number(
                        'name=stall_name,' +
                                a.sort_order || 0
                        'category=category,' +
                            ) -
                         '_pageName=page_name',
                            Number(
                                b.sort_order || 0
                            )
                         );


                    }
                        'stall_id IN (' +
                )
                        makeInClause(
                .forEach(
                            stallIds
                    function ( offering ) {
                        ) +
                        ')',


                         const placementId =
                         100
                            String(
                                offering
                                    .placement_id
                            );


                    )
                    : Promise.resolve(
                        []
                    ),


                        if (
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {


                            offeringsByPlacement[
                festivalIds.length
                                placementId
                    ? cargoQuery(
                            ] = [];


                         }
                         'Festivals',


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


                         offeringsByPlacement[
                         'festival_id IN (' +
                            placementId
                         makeInClause(
                         ].push(
                             festivalIds
                             offering
                        ) +
                         );
                         ')',


                    }
                        100
                );


                    )
                    : Promise.resolve(
                        []
                    ),


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


                        const placement =
                venueIds.length
                            placementMap[
                    ? cargoQuery(
                                placementId
                            ] || null;


                        'Venues',


                         if ( !placement ) {
                         'venue_id=venue_id,' +
                        'name=venue_name,' +
                        'area_id=area_id,' +
                        '_pageName=page_name',


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


                                placementId:
                        100
                                    placementId,


                                placement:
                    )
                                    null,
                    : Promise.resolve(
                        []
                    ),


                                stall:
                                    null,


                                festival:
                cargoQuery(
                                    null,


                                venue:
                    'FestivalStallMenuOfferings',
                                    null,


                                area:
                    'placement_id=placement_id,' +
                                    null,
                    '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',


                                menus:
                    'placement_id IN (' +
                                    []
                    makeInClause(
                        placementIds
                    ) +
                    ')',


                            };
                    100


                        }
                )


            ] ).then(
                function ( results ) {


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


                        placements:
                            placements,


                         const festival =
                         stalls:
                             festivalMap[
                             results[ 0 ],
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                        festivals:
                            results[ 1 ],


                         const venue =
                         venues:
                             venueMap[
                             results[ 2 ],
                                String(
                                    placement.venue_id
                                )
                            ] || null;


                        offerings:
                            results[ 3 ]


                        let area = null;
                    };


                }
            );


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


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




                        const offerings =
            const menuItemIds =
                             offeringsByPlacement[
                uniqueIds(
                                placementId
                    data.offerings.map(
                            ] || [];
                        function ( row ) {
                             return row.menu_item_id;
                        }
                    )
                );




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


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


                        'Areas',


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


                /*
                        'area_id IN (' +
                * どの出店の商品か
                        makeInClause(
                */
                            areaIds
                placementId:
                        ) +
                    placementId,
                        ')',


                        100


                menuName:
                    )
                     menu.menu_name ||
                     : Promise.resolve(
                     '商品',
                        []
                     ),




                 category:
                 menuItemIds.length
                    menu.item_category ||
                     ? cargoQuery(
                     '',


                        'StallMenuItems',


                /*
'menu_item_id=menu_item_id,' +
                * 表示価格
'stall_id=stall_id,' +
                */
'name=menu_name,' +
                price:
'item_category=item_category',
                    cleanNumber(
                        offering.price
                    ),


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


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


                    )
                    : Promise.resolve(
                        []
                    )


                 servingQuantity:
            ] ).then(
                    cleanNumber(
                 function ( results ) {
                        offering
                            .serving_quantity
                    ),


                    data.areas =
                        results[ 0 ];


                servingUnit:
                     data.menuItems =
                     offering
                         results[ 1 ];
                         .serving_unit ||
                    '',


                    return data;


                }
            );


                /*
        }
                * 表示用単位価格
    ).then(
                */
        function ( data ) {
                unitPrice:
                    getUnitPrice(
                        offering
                    ),


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


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


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


                availability:
                    formatAvailability(
                        offering
                            .availability
                    ),


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


                verification:
                    formatVerification(
                        offering
                            .verification_status
                    ),


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


                isLowestPrice:
                    false,


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


                isLowestUnitPrice:
                    false


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


        }
    );


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


return {


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


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


    stall:
                    }
        stall,
                )
                .forEach(
                    function ( offering ) {


    festival:
                        const placementId =
        festival,
                            String(
                                offering
                                    .placement_id
                            );


    venue:
        venue,


    area:
                        if (
        area,
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {


    menus:
                            offeringsByPlacement[
        menus
                                placementId
                            ] = [];


};
                        }


}
);
                       


/* =====================================
                        offeringsByPlacement[
* 最安価格・最安単位価格
                            placementId
*
                        ].push(
* 「同じ商品名 + 同じ単位」
                            offering
* の商品だけを比較する
                        );
* ===================================== */


function markBestPrices(
                    }
    compareData
                );
) {


    const allMenus = [];


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


    /* =================================
                        const placement =
    * 比較文字列を正規化
                            placementMap[
    *
                                placementId
    * 例:
                            ] || null;
    * "たこ焼き"
    * " たこ焼き "
    *
    * を同じものとして扱う
    * ================================= */


    function normalizeCompareText(
        value
    ) {


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


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


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


            text =
                                placement:
                text.normalize(
                                    null,
                    'NFKC'
                );


        }
                                stall:
                                    null,


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


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


        return text;
                                area:
                                    null,


    }
                                menus:
                                    []


                            };


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


    compareData.forEach(
        function ( data ) {


            if (
                        const stall =
                !data.menus ||
                            stallMap[
                !Array.isArray(
                                String(
                    data.menus
                                    placement.stall_id
                )
                                )
            ) {
                            ] || null;
                return;
            }




            data.menus.forEach(
                        const festival =
                function ( menu ) {
                            festivalMap[
                                String(
                                    placement.festival_id
                                )
                            ] || null;


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


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




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




                    /*
                         if (
                    * 比較用単位
                             venue &&
                    */
                            venue.area_id
                    menu.compareUnit =
                         ) {
                         normalizeCompareText(
                             menu.servingUnit
                         );


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


                    allMenus.push(
                         }
                         menu
                    );


                }
            );


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




    /* =================================
/*
    * 商品名+単位ごとのグループ
* Placementに紐づくメニューを生成
    *
*/
    * 例:
const menus =
    *
    offerings.map(
    * たこ焼き + 個
        function ( offering ) {
    * 焼きそば + パック
    * りんご飴 + 本
    * ================================= */


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




    allMenus.forEach(
            return {
        function ( menu ) {


            /*
                /*
            * 商品名が無ければ比較しない
                * どの出店の商品か
            */
                */
            if (
                 placementId:
                !menu.compareMenuName
                    placementId,
            ) {
                 return;
            }




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




            const groupKey =
                category:
                menu.compareMenuName +
                    menu.item_category ||
                '||' +
                    '',
                menu.compareUnit;




            if (
                /*
                 !groups[
                * 表示価格
                     groupKey
                */
                ]
                 price:
            ) {
                     cleanNumber(
                        offering.price
                    ),
 


                 groups[
                 /*
                     groupKey
                * 比較用価格
                ] = [];
                */
                priceValue:
                     toFiniteNumber(
                        offering.price
                    ),


            }


                servingQuantity:
                    cleanNumber(
                        offering
                            .serving_quantity
                    ),


            groups[
                groupKey
            ].push(
                menu
            );


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




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


    Object.keys(
                /*
        groups
                * 表示用単位価格
    ).forEach(
                */
        function ( groupKey ) {
                unitPrice:
                    getUnitPrice(
                        offering
                    ),


            const menus =
                groups[
                    groupKey
                ];


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


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


            const placementIds =
                 availability:
                 [
                     formatAvailability(
                     ...new Set(
                         offering
                         menus.map(
                            .availability
                            function ( menu ) {
                    ),


                                return String(
                                    menu.placementId
                                );


                            }
                verification:
                         )
                    formatVerification(
                     )
                         offering
                ];
                            .verification_status
                     ),




            if (
                 isLowestPrice:
                 placementIds.length < 2
                    false,
            ) {


                return;


            }
                isLowestUnitPrice:
                    false


            };


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


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


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


                    }
    placementId:
                );
        placementId,


    placement:
        placement,


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


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


                            }
    venue:
                        )
        venue,
                    )
                ];


    area:
        area,


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


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


                                return menu
}
                                    .priceValue;
);
                       


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


function markBestPrices(
    compareData
) {


                priceCandidates.forEach(
    const allMenus = [];
                    function ( menu ) {


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


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


                        }
    function normalizeCompareText(
        value
    ) {


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


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


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


             /* =============================
             text =
            * 最安単位価格
                text.normalize(
            *
                    'NFKC'
            * 同商品+同単位で
                );
            * price / quantity を比較
            * ============================= */


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


                        return (
        /*
                            menu.unitPriceValue !==
        * 連続空白を1つにする
                                null &&
        */
                            Number.isFinite(
        text =
                                menu.unitPriceValue
            text.replace(
                            )
                /\s+/g,
                        );
                ' '
            );


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


        return text;


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


                                return String(
                                    menu.placementId
                                );


                            }
    /* =================================
                        )
    * 全メニューを集める
                    )
    * ================================= */
                ];


    compareData.forEach(
        function ( data ) {


             if (
             if (
                 unitPricePlacementIds.length >= 2
                 !data.menus ||
                !Array.isArray(
                    data.menus
                )
             ) {
             ) {
                return;
            }


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


                                return menu
            data.menus.forEach(
                                    .unitPriceValue;
                function ( menu ) {


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


                    menu.isLowestUnitPrice =
                        false;


                unitPriceCandidates.forEach(
                    function ( menu ) {


                        /*
                    /*
                        * 割り算による
                    * 比較用の商品名
                        * 浮動小数誤差対策
                    */
                        */
                    menu.compareMenuName =
                         if (
                         normalizeCompareText(
                             Math.abs(
                             menu.menuName
                                menu.unitPriceValue -
                         );
                                lowestUnitPrice
                            ) <
                            0.000001
                         ) {


                            menu.isLowestUnitPrice =
                                true;


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


                    }
                );


             }
                    allMenus.push(
                        menu
                    );
 
                }
             );


         }
         }
     );
     );
}
/* =====================================
* 商品別比較サマリー
*
* 同じ商品名 + 同じ単位でグループ化
* ===================================== */
function renderProductGroupSummary(
    compareData
) {
    const comparePage =
        document.getElementById(
            'stall-compare-page'
        );
    if (
        !comparePage
    ) {
        return;
    }




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


     function normalizeText(
     const groups = {};
        value
    ) {


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


        let text =
    allMenus.forEach(
            String(
        function ( menu ) {
                value
            ).trim();


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


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


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


        }


            if (
                !groups[
                    groupKey
                ]
            ) {


        text =
                groups[
            text.replace(
                    groupKey
                 /\s+/g,
                 ] = [];
                ' '
            );


        return text;
            }


    }


/* =================================
            groups[
* 屋台ページリンクを生成
                groupKey
* ================================= */
            ].push(
                menu
            );


function appendStallLinks(
        }
     container,
     );
    items
) {


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


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


     items.forEach(
     Object.keys(
         function ( item ) {
        groups
    ).forEach(
         function ( groupKey ) {
 
            const menus =
                groups[
                    groupKey
                ];


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


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


             if (
             const placementIds =
                 seen[
                 [
                     key
                     ...new Set(
                ]
                        menus.map(
            ) {
                            function ( menu ) {
                return;
            }


                                return String(
                                    menu.placementId
                                );


            seen[
                            }
                 key
                        )
            ] = true;
                    )
                 ];




             stalls.push(
             if (
                 {
                 placementIds.length < 2
                    name:
            ) {
                        item.stallName,


                    page:
                 return;
                        item.stallPage
                 }
            );


        }
            }
    );




    stalls.forEach(
            /* =============================
        function (
            * 最安価格
            stall,
            *
            index
            * 同商品+同単位の
        ) {
            * 販売価格を比較
            * ============================= */


             /*
             const priceCandidates =
            * 2件目以降の区切り
                menus.filter(
            */
                    function ( menu ) {
            if (
 
                index > 0
                        return (
            ) {
                            menu.priceValue !==
                                null &&
                            Number.isFinite(
                                menu.priceValue
                            )
                        );


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




             /*
             /*
             * ページが存在する場合
             * 価格が登録されている
             * リンクにする
             * 出店が2件以上あるか
             */
             */
            const pricePlacementIds =
                [
                    ...new Set(
                        priceCandidates.map(
                            function ( menu ) {
                                return String(
                                    menu.placementId
                                );
                            }
                        )
                    )
                ];
             if (
             if (
                 stall.page
                 pricePlacementIds.length >= 2
             ) {
             ) {


                 const link =
                 const lowestPrice =
                     document.createElement(
                     Math.min.apply(
                         'a'
                         null,
                    );
                        priceCandidates.map(
                            function ( menu ) {
 
                                return menu
                                    .priceValue;


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


                link.textContent =
                    stall.name;


                 link.className =
                 priceCandidates.forEach(
                     'stall-product-group-stall-link';
                     function ( menu ) {


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


                container.appendChild(
                            menu.isLowestPrice =
                    link
                                true;
                );


            } else {
                        }


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


             }
             }


        }
    );


}
            /* =============================
            * 最安単位価格
            *
            * 同商品+同単位で
            * price / quantity を比較
            * ============================= */
 
            const unitPriceCandidates =
                menus.filter(
                    function ( menu ) {


    /* =================================
                        return (
    * 商品グループ作成
                            menu.unitPriceValue !==
    * ================================= */
                                null &&
                            Number.isFinite(
                                menu.unitPriceValue
                            )
                        );


    const groups = {};
                    }
                );




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


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


                            }
                        )
                    )
                ];


            data.menus.forEach(
                function ( menu ) {


                    const menuName =
            if (
                        normalizeText(
                unitPricePlacementIds.length >= 2
                            menu.menuName
            ) {
                        );


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


                                return menu
                                    .unitPriceValue;


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




                    const key =
                unitPriceCandidates.forEach(
                        menuName.toLowerCase() +
                    function ( menu ) {
                        '||' +
                        unit.toLowerCase();


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


                    if (
                             menu.isLowestUnitPrice =
                        !groups[
                                true;
                             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
                : '屋台',


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


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


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


                }
            );


    /* =================================
    * 文字列正規化
    * ================================= */
    function normalizeText(
        value
    ) {
        if (
            value === undefined ||
            value === null
        ) {
            return '';
         }
         }
    );


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


    const groupKeys =
        Object.keys(
            groups
        );


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


    if (
            text =
        groupKeys.length === 0
                text.normalize(
    ) {
                    'NFKC'
        return;
                );
    }


        }


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


    const summary =
        text =
        document.createElement(
            text.replace(
            'section'
                /\s+/g,
        );
                ' '
            );


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


    }


    const title =
/* =================================
        document.createElement(
* 屋台ページリンクを生成
            'h2'
* ================================= */
        );


     title.className =
function appendStallLinks(
        'stall-product-group-summary-title';
    container,
     items
) {


     title.textContent =
     const stalls = [];
        '商品別比較サマリー';
    const seen = {};




     summary.appendChild(
     items.forEach(
         title
         function ( item ) {
    );


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


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


    groupKeys.forEach(
            if (
        function ( key ) {
                 seen[
 
            const group =
                 groups[
                     key
                     key
                 ];
                 ]
            ) {
                return;
            }


            const items =
                group.items;


            seen[
                key
            ] = true;


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


                                return item
            stalls.push(
                                    .placementId;
                {
                    name:
                        item.stallName,


                            }
                    page:
                         )
                         item.stallPage
                    )
                }
                ];
            );


        }
    );


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


             card.className =
    stalls.forEach(
                 'stall-product-group-card';
        function (
            stall,
            index
        ) {
 
             /*
            * 2件目以降の区切り
            */
            if (
                 index > 0
            ) {


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


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


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


             heading.className =
             /*
                 'stall-product-group-name';
            * ページが存在する場合
            * リンクにする
            */
            if (
                 stall.page
            ) {


            heading.textContent =
                const link =
                group.menuName +
                    document.createElement(
                ' / ' +
                        'a'
                group.unit;
                    );


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


            card.appendChild(
                link.textContent =
                heading
                    stall.name;
            );


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


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


            const count =
                 container.appendChild(
                 document.createElement(
                     link
                     'div'
                 );
                 );


             count.className =
             } else {
                'stall-product-group-count';


            count.textContent =
                /*
                '比較店舗:' +
                * page_nameが取得できない場合
                placementIds.length +
                * 普通の文字として表示
                 '店';
                */
                container.appendChild(
                    document.createTextNode(
                        stall.name
                    )
                 );
 
            }


        }
    );


            card.appendChild(
}
                count
            );


/* =============================
    /* =================================
* 対象店舗リンク
    * 商品グループ作成
* ============================= */
    * ================================= */


const stallList =
    const groups = {};
    document.createElement(
        'div'
    );


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


    compareData.forEach(
        function ( data ) {


const stallListLabel =
            if (
    document.createElement(
                !data.menus ||
        'span'
                !Array.isArray(
    );
                    data.menus
                )
            ) {
                return;
            }


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


stallListLabel.textContent =
            data.menus.forEach(
    '対象店舗:';
                function ( menu ) {


                    const menuName =
                        normalizeText(
                            menu.menuName
                        );


stallList.appendChild(
                    const unit =
    stallListLabel
                        normalizeText(
);
                            menu.servingUnit
                        );




/*
                    /*
* 屋台名をリンクとして追加
                    * 商品名または単位が無いものは
*/
                    * 商品比較サマリーから除外
appendStallLinks(
                    */
    stallList,
                    if (
    items
                        !menuName ||
);
                        !unit
                    ) {
                        return;
                    }




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


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


            if (
                    if (
                placementIds.length < 2
                        !groups[
            ) {
                            key
                        ]
                    ) {


                const notice =
                        groups[
                    document.createElement(
                            key
                         'div'
                         ] = {
                    );


                notice.className =
                            menuName:
                    'stall-product-group-notice';
                                menuName,


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


                            items:
                                []


                card.appendChild(
                        };
                    notice
                );


            }
                    }




            /* =============================
                    groups[
            * 最安価格の商品
    key
            * ============================= */
].items.push(
    {


             const lowestPriceItems =
        placementId:
                 items.filter(
             String(
                    function ( item ) {
                 menu.placementId
            ),


                        return (
        stallName:
                            item.menu
            (
                                .isLowestPrice ===
                data.stall &&
                            true
                data.stall.stall_name
                        );
            )
                ? data.stall.stall_name
                : '屋台',


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


        menu:
            menu


            if (
    }
                lowestPriceItems.length > 0
);
            ) {


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


        }
    );


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


                row.className =
    const groupKeys =
                    'stall-product-group-best';
        Object.keys(
            groups
        );




                const label =
    if (
                    document.createElement(
        groupKeys.length === 0
                        'span'
    ) {
                    );
        return;
 
    }
                label.className =
                    'stall-product-group-label';


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


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


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


                value.textContent =
    summary.className =
                    lowestPrice +
        'stall-product-group-summary';
                    '';




                row.appendChild(
    const title =
                    label
        document.createElement(
                );
            'h2'
        );


                row.appendChild(
    title.className =
                    value
        'stall-product-group-summary-title';
                );


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


                card.appendChild(
                    row
                );


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


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


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


const shopLabel =
     groupKeys.forEach(
     document.createElement(
         function ( key ) {
         'span'
    );


shopLabel.className =
            const group =
    'stall-product-group-label';
                groups[
                    key
                ];


shopLabel.textContent =
            const items =
    '最安:';
                group.items;




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


/*
                                return item
* 最安店舗をリンク表示
                                    .placementId;
*/
 
appendStallLinks(
                            }
    shopRow,
                        )
    lowestPriceItems
                    )
);
                ];




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


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




             /* =============================
             /* =============================
             * 最安単位価格
             * 商品名
             * ============================= */
             * ============================= */


             const lowestUnitItems =
             const heading =
                 items.filter(
                 document.createElement(
                     function ( item ) {
                     'h3'
                );


                        return (
            heading.className =
                            item.menu
                'stall-product-group-name';
                                .isLowestUnitPrice ===
                            true
                        );


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




             if (
             card.appendChild(
                 lowestUnitItems.length > 0
                 heading
             ) {
             );


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


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


                /*
            const count =
                * 小数表示調整
                document.createElement(
                */
                    'div'
                const displayUnitPrice =
                );
                    Math.round(
                        unitPrice *
                        100
                    ) /
                    100;


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


                 const row =
            count.textContent =
                    document.createElement(
                 '比較店舗:' +
                        'div'
                placementIds.length +
                    );
                '';


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


            card.appendChild(
                count
            );


                const label =
/* =============================
                    document.createElement(
* 対象店舗リンク
                        'span'
* ============================= */
                    );


                label.className =
const stallList =
                    'stall-product-group-label';
    document.createElement(
        'div'
    );


                label.textContent =
stallList.className =
                    '最安単位価格:';
    'stall-product-group-stalls';




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


                value.textContent =
stallListLabel.className =
                    displayUnitPrice +
    'stall-product-group-label';
                    '円/' +
                    group.unit;


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


                row.appendChild(
                    label
                );


                row.appendChild(
stallList.appendChild(
                    value
    stallListLabel
                );
);




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


            }


card.appendChild(
    stallList
);


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


        }
            if (
    );
                placementIds.length < 2
            ) {


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


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


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


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


markBestPrices(
                card.appendChild(
    compareData
                    notice
);
                );


            }


renderComparison(
    compareData
);


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


/*
            const lowestPriceItems =
* 詳細比較表を描画した後に
                items.filter(
* 商品別サマリーを追加
                    function ( item ) {
*/
renderProductGroupSummary(
    compareData
);


        }
                        return (
    ).catch(
                            item.menu
        function ( error ) {
                                .isLowestPrice ===
                            true
                        );


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




             renderMessage(
             if (
                 '比較データの取得中にエラーが発生しました。'
                 lowestPriceItems.length > 0
             );
             ) {


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




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


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


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


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


    if (statusSelect) {
                 label.className =
        Array.from(statusSelect.options).forEach(function (option) {
                    'stall-product-group-label';
            if (statusLabels[option.value]) {
                 option.textContent = statusLabels[option.value];
            }
        });
    }


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


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


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


if (yearInput) {
                value.textContent =
    yearInput.inputMode = 'numeric';
                    lowestPrice +
    yearInput.maxLength = 4;
                    '';


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


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


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


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


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


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 shopRow =
     document.createElement(
        'div'
    );


     const validateAccuracy = function () {
shopRow.className =
        const value = accuracyInput.value.trim();
     'stall-product-group-shop';
 
 
const shopLabel =
    document.createElement(
        'span'
    );
 
shopLabel.className =
    'stall-product-group-label';


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


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


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


const closingTimeInput = document.querySelector(
 
     'input[name="FestivalStallPlacement[closing_time]"]'
/*
* 最安店舗をリンク表示
*/
appendStallLinks(
     shopRow,
    lowestPriceItems
);
);


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


function setupTimeValidation(input, label) {
card.appendChild(
     if (!input) {
     shopRow
        return;
);
    }


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


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


        input.setCustomValidity('');
            /* =============================
            * 最安単位価格
            * ============================= */
 
            const lowestUnitItems =
                items.filter(
                    function ( item ) {
 
                        return (
                            item.menu
                                .isLowestUnitPrice ===
                            true
                        );
 
                    }
                );


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


    input.addEventListener('input', validateTime);
            if (
    input.addEventListener('change', validateTime);
                lowestUnitItems.length > 0
    input.addEventListener('invalid', validateTime);
            ) {


    validateTime();
                const unitPrice =
}
                    lowestUnitItems[
                        0
                    ].menu.unitPriceValue;


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


const longitudeInput = document.querySelector(
                /*
    'input[name="FestivalStallPlacement[longitude]"]'
                * 小数表示調整
);
                */
                const displayUnitPrice =
                    Math.round(
                        unitPrice *
                        100
                    ) /
                    100;


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


    input.inputMode = 'decimal';
                const row =
                    document.createElement(
                        'div'
                    );


    const validateCoordinate = function () {
                row.className =
        const value = input.value.trim();
                    'stall-product-group-best-unit';


        input.setCustomValidity('');


        /*
                const label =
        * exact または approximate の場合は
                    document.createElement(
        * 緯度・経度を必須にする。
                        'span'
        */
                    );
        if (value === '') {
 
            if (
                label.className =
                positionSelect &&
                    'stall-product-group-label';
                 (
 
                     positionSelect.value === 'exact' ||
                label.textContent =
                     positionSelect.value === 'approximate'
                    '最安単位価格:';
                 )
 
            ) {
 
                 input.setCustomValidity(
                 const value =
                     label +
                     document.createElement(
                     'は「位置確認済み」または「おおよその位置」を選択した場合は必須です。'
                        'strong'
                     );
 
                value.textContent =
                    displayUnitPrice +
                    '円/' +
                    group.unit;
 
 
                row.appendChild(
                    label
                 );
 
                 row.appendChild(
                     value
                );
 
 
                card.appendChild(
                     row
                 );
                 );
             }
             }


            return;
        }


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


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


        if (number < min || number > max) {
    /*
            input.setCustomValidity(
    * 比較表の一番上へ追加
                label +
    */
                'は' +
     comparePage.insertBefore(
                min +
         summary,
                '〜' +
         comparePage.firstChild
                max +
                'の範囲で入力してください。'
            );
        }
    };
 
     input.addEventListener(
         'input',
         validateCoordinate
     );
     );


    input.addEventListener(
}
        'change',
        validateCoordinate
    );


    input.addEventListener(
/* =================================
        'invalid',
* 最安値を自動判定
        validateCoordinate
* ================================= */
    );


     validateCoordinate();
markBestPrices(
     compareData
);


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


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


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


/*
/*
  * 位置情報の状態を変更した場合、
  * 詳細比較表を描画した後に
  * 緯度・経度を再検証する。
  * 商品別サマリーを追加
  */
  */
if (positionSelect) {
renderProductGroupSummary(
     positionSelect.addEventListener(
     compareData
        'change',
);
        function () {
            if (validateLatitude) {
                validateLatitude();
            }


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


if (sourceUrlInput) {
            console.error(
    sourceUrlInput.inputMode = 'url';
                'Placement比較データ取得エラー:',
                error
            );


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


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


        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();
$(function () {
}
const statusLabels = {
      
     active: '出店中・出店予定',
     const sortOrderInput = document.querySelector(
     cancelled: '出店中止',
     'input[name="FestivalStallPlacement[sort_order]"]'
     unknown: '未確認'
);
};


if (sortOrderInput) {
    const statusSelect = document.querySelector(
    sortOrderInput.inputMode = 'numeric';
        'select[name="FestivalStallPlacement[status]"]'
    );


     const validateSortOrder = function () {
    if (statusSelect) {
        const value = sortOrderInput.value.trim();
        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;


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


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


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


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


/**
const positionSelect = document.querySelector(
* FestivalStallPlacement - 最終確認日の未来日チェック
    'select[name="FestivalStallPlacement[position_status]"]'
*/
);
(function () {
'use strict';


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


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


dateInput.dataset.lastConfirmedValidation = '1';
    const validateAccuracy = function () {
        const value = accuracyInput.value.trim();


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


if (!widget) {
    accuracyInput.addEventListener('input', validateAccuracy);
return null;
    accuracyInput.addEventListener('change', validateAccuracy);
}
    accuracyInput.addEventListener('invalid', validateAccuracy);


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


function getErrorElement() {
const closingTimeInput = document.querySelector(
const widget = dateInput.closest('.oo-ui-widget');
    'input[name="FestivalStallPlacement[closing_time]"]'
);


if (!widget) {
const timePattern = /^([01]\d|2[0-3]):[0-5]\d$/;
return null;
}


let error = widget.parentNode.querySelector(
function setupTimeValidation(input, label) {
'.stall-last-confirmed-error'
    if (!input) {
);
        return;
    }


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


widget.insertAdjacentElement('afterend', error);
    const validateTime = function () {
}
        const value = input.value.trim();


return error;
        input.setCustomValidity('');
}


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


if (!visibleInput || !error) {
    input.addEventListener('input', validateTime);
return;
    input.addEventListener('change', validateTime);
}
    input.addEventListener('invalid', validateTime);


let message;
    validateTime();
}


if (dateInput.validity.rangeOverflow) {
setupTimeValidation(openingTimeInput, '営業開始時刻');
const maxDate = dateInput.max.replace(/-/g, '/');
setupTimeValidation(closingTimeInput, '営業終了時刻');
   
    const latitudeInput = document.querySelector(
    'input[name="FestivalStallPlacement[latitude]"]'
);


message =
const longitudeInput = document.querySelector(
'未来の日付は入力できません。' +
    'input[name="FestivalStallPlacement[longitude]"]'
maxDate +
);
'以前の日付を入力してください。';
} else {
message =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}


error.textContent = message;
function setupCoordinateValidation(input, label, min, max) {
error.hidden = false;
    if (!input) {
        return null;
    }


visibleInput.setAttribute('aria-invalid', 'true');
    input.inputMode = 'decimal';
}


function clearError() {
    const validateCoordinate = function () {
const visibleInput = getVisibleInput();
        const value = input.value.trim();
const error = getErrorElement();


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


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


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


showError();
            const otherInput =
                input === latitudeInput
                    ? longitudeInput
                    : latitudeInput;


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


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


/*
        /*
* ユーザーが日付を修正したら
        * 数値形式チェック
* 有効になった時点でエラーを消す。
        */
*/
        if (!/^-?\d+(\.\d+)?$/.test(value)) {
const form = dateInput.form;
             input.setCustomValidity(
 
                label + 'は数値で入力してください。'
if (form) {
             );
    function handleDateChange(event) {
        const currentWidget =
             dateInput.closest('.oo-ui-widget');
 
        if (
             !currentWidget ||
            !currentWidget.contains(event.target)
        ) {
             return;
             return;
         }
         }


         window.setTimeout(function () {
         /*
             if (dateInput.validity.valid) {
        * 範囲チェック
                 clearError();
        */
            } else if (
        const number = Number(value);
                 dateInput.validity.rangeOverflow
 
            ) {
        if (number < min || number > max) {
                 showError();
             input.setCustomValidity(
            }
                 label +
         }, 0);
                'は' +
     }
                min +
                 '〜' +
                max +
                 'の範囲で入力してください。'
            );
         }
     };


     form.addEventListener(
     input.addEventListener(
         'input',
         'input',
         handleDateChange
         validateCoordinate
     );
     );


     form.addEventListener(
     input.addEventListener(
         'change',
         'change',
         handleDateChange
         validateCoordinate
    );
 
    input.addEventListener(
        'invalid',
        validateCoordinate
     );
     );
    validateCoordinate();


     /*
     /*
     * Page Forms のカレンダー選択では
     * position_status変更時に
     * visible input に blur が発生する。
     * 再チェックできるよう関数を返す。
    * blur は通常バブルしないため capture=true。
     */
     */
     form.addEventListener(
     return validateCoordinate;
        'blur',
        handleDateChange,
        true
    );
}
}
});
}


if (document.readyState === 'loading') {
const validateLatitude =
document.addEventListener(
    setupCoordinateValidation(
'DOMContentLoaded',
        latitudeInput,
setupLastConfirmedValidation
        '緯度',
);
        20,
} else {
        46
setupLastConfirmedValidation();
    );
}


mw.hook('wikipage.content').add(function () {
const validateLongitude =
setupLastConfirmedValidation();
    setupCoordinateValidation(
});
        longitudeInput,
})();
        '経度',
        122,
        154
    );


/**
/*
* FestivalStallPlacement
  * 一方の座標を変更した場合、
* Cargo既存レコード候補警告 V2
  * 反対側のペア整合性も再検証する。
*
* 同じ festival + year + venue + stall があれば
  * 警告と既存ページへのリンクを表示する。
  * 保存自体は禁止しない。
  */
  */
mw.loader.using([
if (
'mediawiki.api',
    latitudeInput &&
'mediawiki.util'
    validateLongitude
]).then(function () {
) {
'use strict';
    latitudeInput.addEventListener(
        'input',
        validateLongitude
    );


const api = new mw.Api();
    latitudeInput.addEventListener(
        'change',
        validateLongitude
    );
}


function setupDuplicateWarning() {
if (
const form = document.getElementById('pfForm');
    longitudeInput &&
    validateLatitude
) {
    longitudeInput.addEventListener(
        'input',
        validateLatitude
    );


if (!form) {
    longitudeInput.addEventListener(
return;
        'change',
}
        validateLatitude
    );
}


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


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


if (!table) {
if (sourceUrlInput) {
return;
    sourceUrlInput.inputMode = 'url';
}
 
    const validateSourceUrl = function () {
        const value = sourceUrlInput.value.trim();
 
        sourceUrlInput.setCustomValidity('');


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


const warning = document.createElement('div');
        try {
            const url = new URL(value);


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


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


let timer = null;
    validateSourceUrl();
let requestId = 0;
}
   
    const sortOrderInput = document.querySelector(
    'input[name="FestivalStallPlacement[sort_order]"]'
);


function escapeCargo(value) {
if (sortOrderInput) {
return String(value).replace(/'/g, "''");
    sortOrderInput.inputMode = 'numeric';
}


function getField(name) {
    const validateSortOrder = function () {
return form.querySelector(
        const value = sortOrderInput.value.trim();
'[name="FestivalStallPlacement[' +
name +
']"]'
);
}


function cargoQuery(tableName, fields, where, limit) {
        sortOrderInput.setCustomValidity('');
return api.get({
 
action: 'cargoquery',
        if (value !== '' && !/^\d+$/.test(value)) {
tables: tableName,
            sortOrderInput.setCustomValidity(
fields: fields,
                '表示順は0以上の整数で入力してください(例:1)'
where: where,
            );
limit: limit || 50,
        }
format: 'json'
    };
}).then(function (data) {
 
if (
    sortOrderInput.addEventListener('input', validateSortOrder);
!data ||
    sortOrderInput.addEventListener('change', validateSortOrder);
!Array.isArray(data.cargoquery)
    sortOrderInput.addEventListener('invalid', validateSortOrder);
) {
 
return [];
    validateSortOrder();
}
}
   
});
 
/**
* FestivalStallPlacement - 最終確認日の未来日チェック
*/
(function () {
'use strict';


return data.cargoquery.map(function (item) {
function setupLastConfirmedValidation() {
return item.title || item;
const dateInputs = document.querySelectorAll(
});
'input[name="FestivalStallPlacement[last_confirmed]"]'
});
);
}


function resolveId(
dateInputs.forEach(function (dateInput) {
tableName,
if (dateInput.dataset.lastConfirmedValidation === '1') {
idField,
return;
nameField,
value
) {
if (!value) {
return Promise.resolve(null);
}
}


if (/^\d+$/.test(value)) {
dateInput.dataset.lastConfirmedValidation = '1';
return Promise.resolve(value);
 
function getVisibleInput() {
const widget = dateInput.closest('.oo-ui-widget');
 
if (!widget) {
return null;
}
 
return widget.querySelector('input[type="text"]');
}
}


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


if (!widget) {
return null;
return null;
}
}


return String(rows[0].resolved_id);
let error = widget.parentNode.querySelector(
});
'.stall-last-confirmed-error'
}
);


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


function showFailure() {
widget.insertAdjacentElement('afterend', error);
warning.replaceChildren();
}


const text = document.createElement('div');
return error;
}


text.textContent =
function showError() {
'既存データの確認に失敗しました。' +
const visibleInput = getVisibleInput();
'登録はできますが、重複がないかご確認ください。';
const error = getErrorElement();


warning.appendChild(text);
if (!visibleInput || !error) {
warning.hidden = false;
return;
}
}


function showCandidates(rows) {
let message;
warning.replaceChildren();


const positionLabels = {
if (dateInput.validity.rangeOverflow) {
    exact: '位置確認済み',
const maxDate = dateInput.max.replace(/-/g, '/');
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};


const verificationLabels = {
message =
verified: '確認済み',
'未来の日付は入力できません。' +
partially_verified: '一部確認済み',
maxDate +
unverified: '未確認',
'以前の日付を入力してください。';
outdated: '情報が古い可能性あり'
} else {
};
message =
dateInput.validationMessage ||
const statusLabels = {
'正しい日付を入力してください。';
    active: '出店中・出店予定',
}
    cancelled: '出店中止',
    unknown: '未確認'
};


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


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


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


const labelElement =
if (error) {
document.createElement('span');
error.hidden = true;
error.textContent = '';
}


labelElement.className =
if (visibleInput) {
'stall-duplicate-candidate-label';
visibleInput.removeAttribute('aria-invalid');
}
}


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


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


valueElement.className =
const visibleInput = getVisibleInput();
'stall-duplicate-candidate-value';


valueElement.textContent = value;
if (visibleInput) {
window.setTimeout(function () {
visibleInput.focus();
}, 0);
}
});


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


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


const title = document.createElement('strong');
        if (
            !currentWidget ||
            !currentWidget.contains(event.target)
        ) {
            return;
        }


title.className =
        window.setTimeout(function () {
'stall-duplicate-warning-title';
            if (dateInput.validity.valid) {
                clearError();
            } else if (
                dateInput.validity.rangeOverflow
            ) {
                showError();
            }
        }, 0);
    }


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


warning.appendChild(title);
    form.addEventListener(
        'change',
        handleDateChange
    );


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


description.className =
if (document.readyState === 'loading') {
'stall-duplicate-warning-description';
document.addEventListener(
'DOMContentLoaded',
setupLastConfirmedValidation
);
} else {
setupLastConfirmedValidation();
}


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


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


const list = document.createElement('div');
const api = new mw.Api();


list.className =
function setupDuplicateWarning() {
'stall-duplicate-candidate-list';
const form = document.getElementById('pfForm');


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


rows.forEach(function (row) {
if (form.dataset.duplicateWarningV2 === '1') {
const card =
return;
document.createElement('div');
}


card.className =
const table = form.querySelector('.formtable');
'stall-duplicate-candidate';


/*
if (!table) {
* カード見出し
return;
*/
}
const header =
document.createElement('div');


header.className =
form.dataset.duplicateWarningV2 = '1';
'stall-duplicate-candidate-header';


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


heading.textContent =
warning.className = 'stall-duplicate-warning';
'既存の出店情報';
warning.setAttribute('role', 'status');
 
warning.hidden = true;
header.appendChild(heading);
 
card.appendChild(header);


/*
/*
* 出店場所
* 表の中ではなく、表の直前に置く。
* 警告表示でフォームの列幅を崩さない。
*/
*/
addDetail(
table.insertAdjacentElement('beforebegin', warning);
card,
'出店場所',
displayValue(
row.location_note,
'場所メモなし'
)
);


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


/*
function escapeCargo(value) {
* 緯度・経度
return String(value).replace(/'/g, "''");
*/
}
let coordinates =
'位置情報なし';


if (
function getField(name) {
row.latitude !== undefined &&
return form.querySelector(
row.latitude !== null &&
'[name="FestivalStallPlacement[' +
String(row.latitude).trim() !== '' &&
name +
row.longitude !== undefined &&
']"]'
row.longitude !== null &&
);
String(row.longitude).trim() !== ''
) {
coordinates =
String(row.latitude) +
', ' +
String(row.longitude);
}
}


addDetail(
function cargoQuery(tableName, fields, where, limit) {
card,
return api.get({
'緯度・経度',
action: 'cargoquery',
coordinates
tables: tableName,
);
fields: fields,
 
where: where,
/*
limit: limit || 50,
* 位置精度
format: 'json'
*/
}).then(function (data) {
let accuracy = '未確認';
if (
!data ||
!Array.isArray(data.cargoquery)
) {
return [];
}


if (
return data.cargoquery.map(function (item) {
row.position_accuracy_m !== undefined &&
return item.title || item;
row.position_accuracy_m !== null &&
});
String(row.position_accuracy_m).trim() !== ''
});
) {
}
accuracy =
String(row.position_accuracy_m) +
' m';
}


addDetail(
function resolveId(
card,
tableName,
'位置精度',
idField,
accuracy
nameField,
);
value
) {
if (!value) {
return Promise.resolve(null);
}


/*
if (/^\d+$/.test(value)) {
* 出店状態
return Promise.resolve(value);
*/
}
addDetail(
card,
'出店状態',
statusLabels[
row.status
] ||
displayValue(
row.status,
'未確認'
)
);


/*
return cargoQuery(
* 最終確認日
tableName,
*/
idField + '=resolved_id',
let lastConfirmed = '未確認';
nameField +
 
"='" +
if (
escapeCargo(value) +
row.last_confirmed !== undefined &&
"'",
row.last_confirmed !== null &&
2
String(row.last_confirmed).trim() !== ''
).then(function (rows) {
) {
if (rows.length !== 1) {
lastConfirmed =
console.warn(
String(row.last_confirmed)
'IDを一意に取得できません:',
.replace(/-/g, '/');
tableName,
}
value,
rows
);


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


/*
return String(rows[0].resolved_id);
* 確認状態
});
*/
}
addDetail(
card,
'確認状態',
verificationLabels[
row.verification_status
] ||
displayValue(
row.verification_status,
'未確認'
)
);


/*
function clearWarning() {
* 既存ページへのリンク
warning.hidden = true;
*/
warning.replaceChildren();
const actions =
}
document.createElement('div');


actions.className =
function showFailure() {
'stall-duplicate-candidate-actions';
warning.replaceChildren();


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


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


link.target = '_blank';
warning.appendChild(text);
link.rel = 'noopener';
warning.hidden = false;
}
 
function showCandidates(rows, venueSpecified) {
warning.replaceChildren();


link.textContent =
const positionLabels = {
'既存データを確認';
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};


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


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


warning.appendChild(list);
return String(value);
}


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


footer.className =
const labelElement =
'stall-duplicate-warning-footer';
document.createElement('span');


footer.textContent =
labelElement.className =
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';
'stall-duplicate-candidate-label';


warning.appendChild(footer);
labelElement.textContent = label;


warning.hidden = false;
const valueElement =
}
document.createElement('span');


function checkDuplicates() {
valueElement.className =
const currentRequest = ++requestId;
'stall-duplicate-candidate-value';


/*
valueElement.textContent = value;
* 毎回現在のinput/selectを取得する。
 
* Page Formsが要素を作り直しても対応できる。
row.appendChild(labelElement);
*/
row.appendChild(valueElement);
const stall = getField('stall_id');
const festival = getField('festival_id');
const venue = getField('venue_id');
const year = getField('year');


if (
container.appendChild(row);
!stall ||
!festival ||
!venue ||
!year
) {
clearWarning();
return;
}
}


const stallValue = stall.value.trim();
const title = document.createElement('strong');
const festivalValue = festival.value.trim();
 
const venueValue = venue.value.trim();
title.className =
const yearValue = year.value.trim();
'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');


if (
list.className =
!stallValue ||
'stall-duplicate-candidate-list';
!festivalValue ||
!venueValue ||
!/^\d{4}$/.test(yearValue)
) {
clearWarning();
return;
}


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


if (!ids[0] || !ids[1] || !ids[2]) {
rows.forEach(function (row) {
clearWarning();
const card =
return null;
document.createElement('div');
}


const where =
card.className =
'festival_id=' +
'stall-duplicate-candidate';
ids[1] +
' AND year=' +
yearValue +
' AND venue_id=' +
ids[2] +
' AND stall_id=' +
ids[0];


return cargoQuery(
/*
'FestivalStallPlacements',
* カード見出し
'placement_id=placement_id,' +
*/
'location_note=location_note,' +
const header =
'latitude=latitude,' +
document.createElement('div');
'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(
header.className =
'FestivalStallPlacement候補:',
'stall-duplicate-candidate-header';
where,
rows
);


if (rows.length === 0) {
const heading =
clearWarning();
document.createElement('strong');
return;
}


/*
heading.textContent =
* 今回は警告のみ。
'既存の出店情報';
* 同条件の既存データをすべて表示する。
 
*/
header.appendChild(heading);
showCandidates(rows);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);


showFailure();
card.appendChild(header);
});
}


function scheduleCheck() {
/*
window.clearTimeout(timer);
* 出店場所
*/
addDetail(
card,
'出店場所',
displayValue(
row.location_note,
'場所メモなし'
)
);


timer = window.setTimeout(
/*
checkDuplicates,
* 位置状態
300
*/
);
addDetail(
}
card,
'位置状態',
positionLabels[
row.position_status
] ||
displayValue(
row.position_status,
'位置未確認'
)
);


/*
/*
* form自身へイベントを設定する。
* 緯度・経度
* dropdownが後から置き換わっても拾える。
*/
*/
form.addEventListener('change', function (event) {
let coordinates =
const name = event.target.name || '';
'位置情報なし';


if (
if (
name ===
row.latitude !== undefined &&
'FestivalStallPlacement[stall_id]' ||
row.latitude !== null &&
name ===
String(row.latitude).trim() !== '' &&
'FestivalStallPlacement[festival_id]' ||
row.longitude !== undefined &&
name ===
row.longitude !== null &&
'FestivalStallPlacement[venue_id]' ||
String(row.longitude).trim() !== ''
name ===
) {
'FestivalStallPlacement[year]'
coordinates =
) {
String(row.latitude) +
scheduleCheck();
', ' +
}
String(row.longitude);
});
}


form.addEventListener('input', function (event) {
addDetail(
if (
card,
event.target.name ===
'緯度・経度',
'FestivalStallPlacement[year]'
coordinates
) {
scheduleCheck();
}
});
 
scheduleCheck();
}
 
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupDuplicateWarning
);
);
} else {
setupDuplicateWarning();
}
mw.hook('pf.formSetupAfter').add(
setupDuplicateWarning
);
});


/*
/*
  * FestivalStallMenuOffering
  * 位置精度
* 入力検証・日本語表示
  */
  */
(function () {
let accuracy = '未確認';
    'use strict';
 
if (
row.position_accuracy_m !== undefined &&
row.position_accuracy_m !== null &&
String(row.position_accuracy_m).trim() !== ''
) {
accuracy =
String(row.position_accuracy_m) +
' m';
}


    var FORM_ID = 'pfForm';
addDetail(
card,
'位置精度',
accuracy
);


    var availabilityLabels = {
/*
        available: '販売中',
* 出店状態
        unknown: '未確認'
*/
    };
addDetail(
card,
'出店状態',
statusLabels[
row.status
] ||
displayValue(
row.status,
'未確認'
)
);


    var verificationLabels = {
/*
        verified: '確認済み',
* 最終確認日
        partially_verified: '一部確認済み',
*/
        unverified: '未確認',
let lastConfirmed = '未確認';
        outdated: '情報が古い可能性あり'
    };


    function isOfferingField(element) {
if (
        return !!(
row.last_confirmed !== undefined &&
            element &&
row.last_confirmed !== null &&
            element.name &&
String(row.last_confirmed).trim() !== ''
            element.name.indexOf(
) {
                'FestivalStallMenuOffering['
lastConfirmed =
            ) === 0
String(row.last_confirmed)
        );
.replace(/-/g, '/');
    }
}


    function isTemplateField(element) {
addDetail(
        return !!(
card,
            element &&
'最終確認日',
            element.name &&
lastConfirmed
            element.name.indexOf('[num]') !== -1
);
        );
    }


    function fieldNameEndsWith(element, suffix) {
/*
        return !!(
* 確認状態
            element &&
*/
            element.name &&
addDetail(
            element.name.slice(-suffix.length) === suffix
card,
        );
'確認状態',
    }
verificationLabels[
row.verification_status
] ||
displayValue(
row.verification_status,
'未確認'
)
);


    function localizeSelect(select, labels) {
/*
        if (!select) {
* 既存ページへのリンク
            return;
*/
        }
const actions =
document.createElement('div');


        Array.from(select.options).forEach(
actions.className =
            function (option) {
'stall-duplicate-candidate-actions';
                if (
                    Object.prototype.hasOwnProperty.call(
                        labels,
                        option.value
                    ) &&
                    option.textContent !==
                        labels[option.value]
                ) {
                    option.textContent =
                        labels[option.value];
                }
            }
        );
    }


    function validatePrice(input) {
const link =
        var value = input.value.trim();
document.createElement('a');


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


        if (
link.target = '_blank';
            value !== '' &&
link.rel = 'noopener';
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '価格は0以上の整数で入力してください(例:600)'
            );
        }
    }


    function validateServingQuantity(input) {
link.textContent =
        var value = input.value.trim();
'既存データを確認';


        input.setCustomValidity('');
actions.appendChild(link);
card.appendChild(actions);


        if (value === '') {
list.appendChild(card);
            return;
});
        }


        if (
warning.appendChild(list);
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
        ) {
            input.setCustomValidity(
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
            );
        }
    }


    function validateLimitedQuantity(input) {
const footer =
        var value = input.value.trim();
document.createElement('div');


        input.setCustomValidity('');
footer.className =
'stall-duplicate-warning-footer';


        if (
footer.textContent =
            value !== '' &&
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '限定数量は0以上の整数で入力してください(例:100)'
            );
        }
    }


    function validateSortOrder(input) {
warning.appendChild(footer);
        var value = input.value.trim();


        input.setCustomValidity('');
warning.hidden = false;
}


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


    function validateSourceUrl(input) {
/*
        var value = input.value.trim();
* 毎回現在のinput/selectを取得する。
* Page Formsが要素を作り直しても対応できる。
*/
const stall = getField('stall_id');
const festival = getField('festival_id');
const venue = getField('venue_id');
const year = getField('year');


        input.setCustomValidity('');
if (
!stall ||
!festival ||
!venue ||
!year
) {
clearWarning();
return;
}


        if (value === '') {
const stallValue = stall.value.trim();
            return;
const festivalValue = festival.value.trim();
        }
const venueValue = venue.value.trim();
const yearValue = year.value.trim();


        try {
if (
            var url = new URL(value);
!stallValue ||
!festivalValue ||
!/^\d{4}$/.test(yearValue)
) {
clearWarning();
return;
}


            if (
/*
                url.protocol !== 'http:' &&
* async / await は使わず、
                url.protocol !== 'https:'
* Promise の then() で処理する。
            ) {
*/
                input.setCustomValidity(
Promise.all([
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
resolveId(
                );
'Stalls',
            }
'stall_id',
        } catch (e) {
'name',
            input.setCustomValidity(
stallValue
                '情報元URLを正しいURL形式で入力してください。'
),
            );
resolveId(
        }
'Festivals',
    }
'festival_id',
'name',
festivalValue
),
venueValue !== ''
? resolveId(
'Venues',
'venue_id',
'_pageName',
venueValue
)
: Promise.resolve(null)
])
.then(function (ids) {
if (currentRequest !== requestId) {
return null;
}


    function validateLastConfirmed(input) {
if (
        var value = input.value;
!ids[0] ||
        var max = input.max;
!ids[1] ||
(
venueValue !== '' &&
!ids[2]
)
) {
clearWarning();
return null;
}


        input.setCustomValidity('');
let where =
 
'festival_id=' +
        if (
ids[1] +
            value !== '' &&
' AND year=' +
            max !== '' &&
yearValue +
            value > max
' AND stall_id=' +
        ) {
ids[0];
            input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
    }


    function getVisibleDateInput(dateInput) {
if (ids[2]) {
        var widget =
where +=
            dateInput.closest('.oo-ui-widget');
' AND venue_id=' +
ids[2];
}


        if (!widget) {
return cargoQuery(
            return null;
'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;
}


        return widget.querySelector(
console.log(
            'input[type="text"]'
'FestivalStallPlacement候補:',
        );
where,
    }
rows
);


    function getDateErrorElement(dateInput) {
if (rows.length === 0) {
        var widget =
clearWarning();
            dateInput.closest('.oo-ui-widget');
return;
}


        if (!widget) {
/*
            return null;
* 今回は警告のみ。
        }
* 同条件の既存データをすべて表示する。
*/
const rawPageName =
String(
mw.config.get('wgPageName') ||
''
);


        var next =
const formEditMarker =
            widget.nextElementSibling;
'/FestivalStallPlacement/';


        if (
const markerIndex =
            next &&
rawPageName.indexOf(
            next.classList.contains(
formEditMarker
                'stall-offering-last-confirmed-error'
);
            )
        ) {
            return next;
        }


        var error =
const currentPlacementPage =
            document.createElement('div');
markerIndex >= 0
? rawPageName
.slice(
markerIndex +
formEditMarker.length
)
.replace(/_/g, ' ')
.trim()
: '';


        /*
const filteredRows =
        * 既存の最終確認日エラー用CSSも利用する。
currentPlacementPage
        */
? rows.filter(function (row) {
        error.className =
return (
            'stall-last-confirmed-error ' +
String(
            'stall-offering-last-confirmed-error';
row.page_name ||
''
)
.replace(/_/g, ' ')
.trim() !==
currentPlacementPage
);
})
: rows;


        error.setAttribute(
if (filteredRows.length === 0) {
            'role',
clearWarning();
            'alert'
return;
        );
}


        error.hidden = true;
showCandidates(
filteredRows,
venueValue !== ''
);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);


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


        return error;
function scheduleCheck() {
    }
window.clearTimeout(timer);


    function showDateError(dateInput) {
timer = window.setTimeout(
        var visibleInput =
checkDuplicates,
            getVisibleDateInput(dateInput);
300
);
}


        var error =
/*
            getDateErrorElement(dateInput);
* form自身へイベントを設定する。
* dropdownが後から置き換わっても拾える。
*/
form.addEventListener('change', function (event) {
const name = event.target.name || '';


        if (!error) {
if (
            return;
name ===
        }
'FestivalStallPlacement[stall_id]' ||
 
name ===
        var maxDate =
'FestivalStallPlacement[festival_id]' ||
            dateInput.max
name ===
                ? dateInput.max.replace(/-/g, '/')
'FestivalStallPlacement[venue_id]' ||
                : '';
name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


        if (
form.addEventListener('input', function (event) {
            dateInput.validity.rangeOverflow ||
if (
            (
event.target.name ===
                dateInput.value &&
'FestivalStallPlacement[year]'
                dateInput.max &&
) {
                dateInput.value > dateInput.max
scheduleCheck();
            )
}
        ) {
});
            error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
            error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }


        error.hidden = false;
scheduleCheck();
}


        if (visibleInput) {
if (document.readyState === 'loading') {
            visibleInput.setAttribute(
document.addEventListener(
                'aria-invalid',
'DOMContentLoaded',
                'true'
setupDuplicateWarning
            );
);
        }
} else {
    }
setupDuplicateWarning();
}


    function clearDateError(dateInput) {
mw.hook('pf.formSetupAfter').add(
        var visibleInput =
setupDuplicateWarning
            getVisibleDateInput(dateInput);
);
});


        var widget =
/*
            dateInput.closest('.oo-ui-widget');
* FestivalStallMenuOffering
* 入力検証・日本語表示
*/
(function () {
    'use strict';


         var error = null;
    var FORM_ID = 'pfForm';
 
    var availabilityLabels = {
         available: '販売中',
        unknown: '未確認'
    };
 
    var verificationLabels = {
        verified: '確認済み',
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };


         if (
    function isOfferingField(element) {
             widget &&
         return !!(
             widget.nextElementSibling &&
             element &&
             widget.nextElementSibling.classList.contains(
             element.name &&
                 'stall-offering-last-confirmed-error'
             element.name.indexOf(
             )
                 'FestivalStallMenuOffering['
         ) {
             ) === 0
            error =
         );
                widget.nextElementSibling;
    }
        }


        if (error) {
    function isTemplateField(element) {
             error.hidden = true;
        return !!(
             error.textContent = '';
             element &&
        }
            element.name &&
             element.name.indexOf('[num]') !== -1
        );
    }


        if (visibleInput) {
    function fieldNameEndsWith(element, suffix) {
             visibleInput.removeAttribute(
        return !!(
                'aria-invalid'
             element &&
            );
            element.name &&
        }
            element.name.slice(-suffix.length) === suffix
        );
     }
     }


     function getLimitedQuantityInput(
     function localizeSelect(select, labels) {
        checkbox,
         if (!select) {
        form
             return;
    ) {
         if (!checkbox || !checkbox.name) {
             return null;
         }
         }


         var quantityName =
         Array.from(select.options).forEach(
            checkbox.name.replace(
             function (option) {
                /\[limited\]\[value\]$/,
                if (
                '[limited_quantity]'
                    Object.prototype.hasOwnProperty.call(
             );
                        labels,
 
                        option.value
        return Array.from(
                    ) &&
            form.querySelectorAll(
                    option.textContent !==
                'input[name^="FestivalStallMenuOffering["]'
                        labels[option.value]
            )
                ) {
        ).find(
                    option.textContent =
            function (input) {
                        labels[option.value];
                 return input.name === quantityName;
                 }
             }
             }
         ) || null;
         );
     }
     }


     function updateLimitedState(
     function validatePrice(input) {
        checkbox,
         var value = input.value.trim();
        form,
        clearWhenOff
    ) {
         var quantityInput =
            getLimitedQuantityInput(
                checkbox,
                form
            );


         if (!quantityInput) {
         input.setCustomValidity('');
            return;
        }


         if (checkbox.checked) {
         if (
             quantityInput.disabled = false;
             value !== '' &&
             quantityInput.removeAttribute(
             !/^\d+$/.test(value)
                'aria-disabled'
         ) {
            );
             input.setCustomValidity(
         } else {
                 '価格は0以上の整数で入力してください(例:600)'
            if (clearWhenOff) {
                quantityInput.value = '';
             }
 
            quantityInput.setCustomValidity('');
            quantityInput.disabled = true;
            quantityInput.setAttribute(
                'aria-disabled',
                 'true'
             );
             );
         }
         }
     }
     }


     function validateField(element) {
     function validateServingQuantity(input) {
         if (
         var value = input.value.trim();
            !isOfferingField(element) ||
 
            isTemplateField(element)
        input.setCustomValidity('');
         ) {
 
         if (value === '') {
             return;
             return;
         }
         }


         if (
         if (
             fieldNameEndsWith(
             !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
                element,
                '[price]'
            )
         ) {
         ) {
             validatePrice(element);
             input.setCustomValidity(
             return;
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
             );
         }
         }
    }
    function validateLimitedQuantity(input) {
        var value = input.value.trim();
        input.setCustomValidity('');


         if (
         if (
             fieldNameEndsWith(
             value !== '' &&
                element,
             !/^\d+$/.test(value)
                '[serving_quantity]'
             )
         ) {
         ) {
             validateServingQuantity(element);
             input.setCustomValidity(
             return;
                '限定数量は0以上の整数で入力してください(例:100)'
             );
         }
         }
    }
    function validateSortOrder(input) {
        var value = input.value.trim();
        input.setCustomValidity('');


         if (
         if (
             fieldNameEndsWith(
             value !== '' &&
                element,
             !/^\d+$/.test(value)
                '[limited_quantity]'
             )
         ) {
         ) {
             validateLimitedQuantity(element);
             input.setCustomValidity(
             return;
                '表示順は0以上の整数で入力してください(例:1)'
             );
         }
         }
    }
    function validateSourceUrl(input) {
        var value = input.value.trim();
        input.setCustomValidity('');


         if (
         if (value === '') {
            fieldNameEndsWith(
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
             return;
             return;
         }
         }


         if (
         try {
             fieldNameEndsWith(
            var url = new URL(value);
                 element,
 
                 '[source_url]'
             if (
             )
                 url.protocol !== 'http:' &&
         ) {
                 url.protocol !== 'https:'
             validateSourceUrl(element);
             ) {
             return;
                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 (
         if (
             fieldNameEndsWith(
             value !== '' &&
                element,
            max !== '' &&
                '[last_confirmed]'
             value > max
             )
         ) {
         ) {
             validateLastConfirmed(element);
             input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
    }


            if (element.validity.valid) {
    function getVisibleDateInput(dateInput) {
                clearDateError(element);
        var widget =
            }
            dateInput.closest('.oo-ui-widget');


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


     function initializeFields(form) {
     function getDateErrorElement(dateInput) {
         /*
         var widget =
        * 販売状態を日本語化。
            dateInput.closest('.oo-ui-widget');
        * [num]も変更しておくことで、
 
        * 後から追加されるmultipleにも反映される。
        if (!widget) {
        */
            return null;
         form.querySelectorAll(
         }
            'select[name^="FestivalStallMenuOffering["]' +
 
             '[name$="[availability]"]'
        var next =
         ).forEach(
             widget.nextElementSibling;
             function (select) {
 
                 localizeSelect(
         if (
                    select,
             next &&
                    availabilityLabels
            next.classList.contains(
                );
                 'stall-offering-last-confirmed-error'
            }
            )
         );
        ) {
            return next;
        }
 
         var error =
            document.createElement('div');


         /*
         /*
         * 確認状態を日本語化。
         * 既存の最終確認日エラー用CSSも利用する。
         */
         */
         form.querySelectorAll(
         error.className =
             'select[name^="FestivalStallMenuOffering["]' +
             'stall-last-confirmed-error ' +
             '[name$="[verification_status]"]'
             'stall-offering-last-confirmed-error';
         ).forEach(
 
             function (select) {
         error.setAttribute(
                localizeSelect(
             'role',
                    select,
             'alert'
                    verificationLabels
                );
             }
         );
         );


         /*
         error.hidden = true;
        * 数値入力向けキーボード。
        */
        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(
         widget.insertAdjacentElement(
             'input[name^="FestivalStallMenuOffering["]' +
             'afterend',
             '[name$="[serving_quantity]"]'
             error
        ).forEach(
            function (input) {
                input.inputMode = 'decimal';
            }
         );
         );


         form.querySelectorAll(
         return error;
            'input[name^="FestivalStallMenuOffering["]' +
    }
            '[name$="[source_url]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'url';
            }
        );


        /*
    function showDateError(dateInput) {
        * 限定数量欄のON/OFF。
        var visibleInput =
        */
             getVisibleDateInput(dateInput);
        form.querySelectorAll(
            'input[type="checkbox"]' +
            '[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited][value]"]'
        ).forEach(
            function (checkbox) {
                updateLimitedState(
                    checkbox,
                    form,
                    false
                );
             }
        );


         /*
         var error =
        * 現在値を一度検証。
             getDateErrorElement(dateInput);
        * [num]は除外。
        */
        form.querySelectorAll(
            '[name^="FestivalStallMenuOffering["]'
        ).forEach(
             function (element) {
                validateField(element);
            }
        );
    }


    function setupOfferingValidation() {
         if (!error) {
        var form =
            document.getElementById(
                FORM_ID
            );
 
         if (!form) {
             return;
             return;
         }
         }


         /*
         var maxDate =
        * wikipage.content 等で再度呼ばれても
            dateInput.max
        * イベントを二重登録しない。
                ? dateInput.max.replace(/-/g, '/')
        */
                : '';
 
         if (
         if (
             form.dataset
             dateInput.validity.rangeOverflow ||
                 .offeringValidationInitialized ===
            (
             '1'
                dateInput.value &&
                 dateInput.max &&
                dateInput.value > dateInput.max
             )
         ) {
         ) {
             initializeFields(form);
             error.textContent =
             return;
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
             error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
         }
         }


         form.dataset
         error.hidden = false;
             .offeringValidationInitialized =
 
            '1';
        if (visibleInput) {
             visibleInput.setAttribute(
                'aria-invalid',
                'true'
            );
        }
    }


        /*
    function clearDateError(dateInput) {
        * multipleで後から追加された項目にも効くよう
        var visibleInput =
        * form側でイベント委譲。
             getVisibleDateInput(dateInput);
        */
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
             }
        );


         form.addEventListener(
         var widget =
            'change',
            dateInput.closest('.oo-ui-widget');
            function (event) {
                var target =
                    event.target;


                if (!isOfferingField(target)) {
        var error = null;
                    return;
                }


                if (
        if (
                    target.type === 'checkbox' &&
            widget &&
                    fieldNameEndsWith(
            widget.nextElementSibling &&
                        target,
            widget.nextElementSibling.classList.contains(
                        '[limited][value]'
                'stall-offering-last-confirmed-error'
                    )
            )
                ) {
        ) {
                    updateLimitedState(
            error =
                        target,
                widget.nextElementSibling;
                        form,
        }
                        true
                    );
                }


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


/*
        if (visibleInput) {
* Page Forms のカレンダー選択では、
            visibleInput.removeAttribute(
* visible input に blur が発生する場合がある。
                'aria-invalid'
* 対応する非表示 date input を取得して再検証する。
            );
*/
        }
form.addEventListener(
    }
    'blur',
    function (event) {
var target = event.target;


if (
     function getLimitedQuantityInput(
    !target ||
        checkbox,
     typeof target.closest !== 'function'
        form
) {
     ) {
    return;
         if (!checkbox || !checkbox.name) {
}
             return null;
 
var widget =
     target.closest('.oo-ui-widget');
 
         if (!widget) {
             return;
         }
         }


         var dateInput =
         var quantityName =
             widget.querySelector(
             checkbox.name.replace(
                 'input[type="date"]' +
                 /\[limited\]\[value\]$/,
                '[name^="FestivalStallMenuOffering["]' +
                 '[limited_quantity]'
                 '[name$="[last_confirmed]"]'
             );
             );


         if (
         return Array.from(
             !dateInput ||
            form.querySelectorAll(
             isTemplateField(dateInput)
                'input[name^="FestivalStallMenuOffering["]'
         ) {
            )
        ).find(
            function (input) {
                return input.name === quantityName;
             }
        ) || null;
    }
 
    function updateLimitedState(
        checkbox,
        form,
        clearWhenOff
    ) {
        var quantityInput =
             getLimitedQuantityInput(
                checkbox,
                form
            );
 
         if (!quantityInput) {
             return;
             return;
         }
         }


         window.setTimeout(
         if (checkbox.checked) {
             function () {
            quantityInput.disabled = false;
                 validateField(dateInput);
            quantityInput.removeAttribute(
                'aria-disabled'
             );
        } else {
            if (clearWhenOff) {
                 quantityInput.value = '';
            }


                if (dateInput.validity.valid) {
            quantityInput.setCustomValidity('');
                    clearDateError(dateInput);
            quantityInput.disabled = true;
                } else {
            quantityInput.setAttribute(
                    showDateError(dateInput);
                 'aria-disabled',
                 }
                'true'
            },
             );
             0
        }
        );
     }
    },
     true
);


        /*
    function validateField(element) {
        * invalidイベントは通常bubbleしないため
         if (
        * capture=trueで取得する。
             !isOfferingField(element) ||
        */
             isTemplateField(element)
         form.addEventListener(
        ) {
             'invalid',
            return;
             function (event) {
        }
                var target =
                    event.target;


                if (
        if (
                    !isOfferingField(target) ||
            fieldNameEndsWith(
                    isTemplateField(target)
                element,
                ) {
                '[price]'
                    return;
            )
                }
        ) {
            validatePrice(element);
            return;
        }


                 validateField(target);
        if (
            fieldNameEndsWith(
                element,
                 '[serving_quantity]'
            )
        ) {
            validateServingQuantity(element);
            return;
        }


                if (
        if (
                    fieldNameEndsWith(
            fieldNameEndsWith(
                        target,
                element,
                        '[last_confirmed]'
                '[limited_quantity]'
                    )
            )
                ) {
        ) {
                    event.preventDefault();
            validateLimitedQuantity(element);
            return;
        }


                    showDateError(target);
        if (
            fieldNameEndsWith(
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
            return;
        }


                    var visibleInput =
        if (
                        getVisibleDateInput(
            fieldNameEndsWith(
                            target
                element,
                        );
                '[source_url]'
            )
        ) {
            validateSourceUrl(element);
            return;
        }


                    if (visibleInput) {
        if (
                        window.setTimeout(
            fieldNameEndsWith(
                            function () {
                element,
                                visibleInput.focus();
                '[last_confirmed]'
                            },
            )
                            0
        ) {
                        );
            validateLastConfirmed(element);
                    }
 
                }
            if (element.validity.valid) {
            },
                clearDateError(element);
            true
            }
        );
 
            return;
        }
    }


    function initializeFields(form) {
         /*
         /*
         * 「販売商品を追加」でDOMが増えた場合の初期化。
         * 販売状態を日本語化。
        * [num]も変更しておくことで、
        * 後から追加されるmultipleにも反映される。
         */
         */
         var mutationTimer = null;
         form.querySelectorAll(
 
            'select[name^="FestivalStallMenuOffering["]' +
        var observer =
             '[name$="[availability]"]'
             new MutationObserver(
        ).forEach(
                function () {
            function (select) {
                    window.clearTimeout(
                localizeSelect(
                        mutationTimer
                    select,
                    );
                    availabilityLabels
 
                 );
                    mutationTimer =
                        window.setTimeout(
                            function () {
                                initializeFields(
                                    form
                                );
                            },
                            100
                        );
                 }
            );
 
        observer.observe(
            form,
            {
                childList: true,
                subtree: true
             }
             }
         );
         );


         initializeFields(form);
         /*
    }
        * 確認状態を日本語化。
        */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[verification_status]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    verificationLabels
                );
            }
        );


    if (
        /*
         document.readyState ===
        * 数値入力向けキーボード。
        'loading'
        */
    ) {
         form.querySelectorAll(
         document.addEventListener(
            'input[name^="FestivalStallMenuOffering["]' +
             'DOMContentLoaded',
            '[name$="[price]"],' +
             setupOfferingValidation
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited_quantity]"],' +
            'input[name^="FestivalStallMenuOffering["]' +
            '[name$="[sort_order]"]'
         ).forEach(
             function (input) {
                input.inputMode = 'numeric';
             }
         );
         );
    } else {
        setupOfferingValidation();
    }


    mw.hook(
        form.querySelectorAll(
        'wikipage.content'
            'input[name^="FestivalStallMenuOffering["]' +
    ).add(
            '[name$="[serving_quantity]"]'
         setupOfferingValidation
        ).forEach(
    );
            function (input) {
                input.inputMode = 'decimal';
            }
         );


    mw.hook(
        form.querySelectorAll(
        'pf.formSetupAfter'
            'input[name^="FestivalStallMenuOffering["]' +
    ).add(
            '[name$="[source_url]"]'
         setupOfferingValidation
        ).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
                );
            }
        );


 
        /*
mw.loader.using('mediawiki.api').then(function () {
        * 現在値を一度検証。
    'use strict';
        * [num]は除外。
 
        */
    if (window.__festivalStallMenuFilterInitialized) {
        form.querySelectorAll(
        return;
            '[name^="FestivalStallMenuOffering["]'
        ).forEach(
            function (element) {
                validateField(element);
            }
        );
     }
     }


     window.__festivalStallMenuFilterInitialized = true;
     function setupOfferingValidation() {
        var form =
            document.getElementById(
                FORM_ID
            );


    const STALL_SELECTOR =
        if (!form) {
         'select[name="FestivalStallPlacement[stall_id]"]';
            return;
         }


    const MENU_SELECTOR =
        /*
         'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
        * wikipage.content 等で再度呼ばれても
        * イベントを二重登録しない。
        */
         if (
            form.dataset
                .offeringValidationInitialized ===
            '1'
        ) {
            initializeFields(form);
            return;
        }


    const TEMPLATE_MENU_SELECTOR =
        form.dataset
        'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
            .offeringValidationInitialized =
            '1';


    const api = new mw.Api();
        /*
 
        * multipleで後から追加された項目にも効くよう
    let requestSerial = 0;
        * form側でイベント委譲。
    let observerTimer = null;
        */
    let applying = false;
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
            }
        );
 
        form.addEventListener(
            'change',
            function (event) {
                var target =
                    event.target;


    const menuCache = {};
                if (!isOfferingField(target)) {
                    return;
                }


/*
                if (
* FestivalStallPlacement フォーム以外では
                    target.type === 'checkbox' &&
* この連動機能を起動しない。
                    fieldNameEndsWith(
*/
                        target,
const stallSelect =
                        '[limited][value]'
    document.querySelector(STALL_SELECTOR);
                    )
                ) {
                    updateLimitedState(
                        target,
                        form,
                        true
                    );
                }


if (!stallSelect) {
                validateField(target);
    return;
            }
}
        );


/*
/*
  * Page Formsの雛形が持つ全商品optionを最初に保存
  * Page Forms のカレンダー選択では、
* visible input に blur が発生する場合がある。
* 対応する非表示 date input を取得して再検証する。
  */
  */
const templateSelect =
form.addEventListener(
     document.querySelector(TEMPLATE_MENU_SELECTOR);
    'blur',
     function (event) {
var target = event.target;


if (!templateSelect) {
if (
     console.error(
    !target ||
        '販売商品の雛形SELECTが見つかりません。'
     typeof target.closest !== 'function'
    );
) {
     return;
     return;
}
}


    const masterOptions =
var widget =
        [...templateSelect.options].map(
    target.closest('.oo-ui-widget');
            function (option) {
                return option.cloneNode(true);
            }
        );


    function cargoQuote(value) {
        if (!widget) {
        return "'" + String(value)
            return;
            .replace(/\\/g, '\\\\')
        }
            .replace(/'/g, "\\'") + "'";
    }


    function cargoRows(res) {
        var dateInput =
        return (res.cargoquery || []).map(
            widget.querySelector(
            function (row) {
                'input[type="date"]' +
                 return row.title || {};
                '[name^="FestivalStallMenuOffering["]' +
             }
                 '[name$="[last_confirmed]"]'
        );
             );
    }


    function getRealMenuSelects() {
        if (
        return [
            !dateInput ||
             ...document.querySelectorAll(
             isTemplateField(dateInput)
                MENU_SELECTOR
         ) {
            )
             return;
         ].filter(function (select) {
         }
             return !select.name.includes('[num]');
         });
    }


    function resolveStallId(stallName) {
        window.setTimeout(
            function () {
                validateField(dateInput);


        return api.get({
                if (dateInput.validity.valid) {
            action: 'cargoquery',
                    clearDateError(dateInput);
            format: 'json',
                } else {
            tables: 'Stalls',
                    showDateError(dateInput);
            fields:
                 }
                 'stall_id=stall_id,' +
            },
                'name=name',
             0
             where:
        );
                'name=' +
    },
                cargoQuote(stallName),
    true
            limit: 20
);
        }).then(function (res) {


             const rows =
        /*
                cargoRows(res);
        * invalidイベントは通常bubbleしないため
        * capture=trueで取得する。
        */
        form.addEventListener(
             'invalid',
            function (event) {
                var target =
                    event.target;


            if (rows.length === 1) {
                if (
                return rows[0].stall_id;
                    !isOfferingField(target) ||
            }
                    isTemplateField(target)
                ) {
                    return;
                }


            /*
                 validateField(target);
            * 同名表示が
            * 名前 (ID)
            * になっている場合
            */
            const match =
                 String(stallName)
                    .match(/\((\d+)\)$/);


            if (!match) {
                if (
                throw new Error(
                    fieldNameEndsWith(
                    '屋台を1件に特定できません: ' +
                        target,
                     stallName
                        '[last_confirmed]'
                 );
                     )
            }
                 ) {
                    event.preventDefault();


            return match[1];
                    showDateError(target);
        });
    }


    function loadMenus(stallId) {
                    var visibleInput =
                        getVisibleDateInput(
                            target
                        );


        const key =
                    if (visibleInput) {
            String(stallId);
                        window.setTimeout(
 
                            function () {
        if (menuCache[key]) {
                                visibleInput.focus();
            return Promise.resolve(
                            },
                 menuCache[key]
                            0
             );
                        );
         }
                    }
                 }
             },
            true
        );
 
         /*
        * 「販売商品を追加」でDOMが増えた場合の初期化。
        */
        var mutationTimer = null;


         return api.get({
         var observer =
            action: 'cargoquery',
             new MutationObserver(
            format: 'json',
                 function () {
            tables: 'StallMenuItems',
                    window.clearTimeout(
fields:
                        mutationTimer
    '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 =
                    mutationTimer =
                 cargoRows(res);
                        window.setTimeout(
                            function () {
                                initializeFields(
                                    form
                                );
                            },
                            100
                        );
                 }
            );


             menuCache[key] =
        observer.observe(
                 rows;
            form,
             {
                childList: true,
                 subtree: true
            }
        );


            return rows;
         initializeFields(form);
         });
     }
     }


     function optionBelongsToMenu(
     if (
         option,
         document.readyState ===
         menu
         'loading'
     ) {
     ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingValidation
        );
    } else {
        setupOfferingValidation();
    }


         const name =
    mw.hook(
            String(menu.name || '');
         'wikipage.content'
    ).add(
        setupOfferingValidation
    );


         const id =
    mw.hook(
            String(
         'pf.formSetupAfter'
                menu.menu_item_id || ''
    ).add(
            );
        setupOfferingValidation
    );


        const value =
})();
            String(option.value || '');


        const text =
            String(
                option.textContent || ''
            );


        /*
mw.loader.using('mediawiki.api').then(function () {
        * 商品名が一意
    'use strict';
        */
        if (
            value === name ||
            text === name
        ) {
            return true;
        }


        /*
    if (window.__festivalStallMenuFilterInitialized) {
        * Page Formsによる
         return;
        * 同名商品の識別表示
    }
        *
        * たこ焼き (1)
        * たこ焼き (3)
        */
         const mapped =
            name + ' (' + id + ')';


        return (
    window.__festivalStallMenuFilterInitialized = true;
            value === mapped ||
            text === mapped
        );
    }


     function makeOptions(menus) {
     const STALL_SELECTOR =
        'select[name="FestivalStallPlacement[stall_id]"]';


         const options = [];
    const MENU_SELECTOR =
         'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';


        /*
    const TEMPLATE_MENU_SELECTOR =
        * 空欄
        'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
        */
        const blank =
            masterOptions.find(
                function (option) {
                    return (
                        option.value === ''
                    );
                }
            );


        if (blank) {
    const api = new mw.Api();
            options.push(
                blank.cloneNode(true)
            );
        } else {
            options.push(
                new Option('', '')
            );
        }


        menus.forEach(
    let requestSerial = 0;
            function (menu) {
    let observerTimer = null;
    let applying = false;


                const option =
    const menuCache = {};
                    masterOptions.find(
                        function (candidate) {
                            return optionBelongsToMenu(
                                candidate,
                                menu
                            );
                        }
                    );


                if (option) {
/*
                    options.push(
* FestivalStallPlacement フォーム以外では
                        option.cloneNode(true)
* この連動機能を起動しない。
                    );
*/
                } else {
const stallSelect =
                    console.warn(
    document.querySelector(STALL_SELECTOR);
                        'Page Formsのoptionを特定できません:',
                        menu
                    );
                }
            }
        );


        return options;
if (!stallSelect) {
    }
    return;
}


     function optionSignature(select) {
/*
* Page Formsの雛形が持つ全商品optionを最初に保存
*/
const templateSelect =
     document.querySelector(TEMPLATE_MENU_SELECTOR);


        return [...select.options]
if (!templateSelect) {
            .map(function (option) {
    console.error(
                return (
        '販売商品の雛形SELECTが見つかりません。'
                    option.value +
    );
                    '::' +
    return;
                    option.textContent
}
                );
            })
            .join('||');
    }


     function filterMenuSelects(
     const masterOptions =
        menus,
        [...templateSelect.options].map(
         clearSelection
            function (option) {
    ) {
                return option.cloneNode(true);
            }
         );


         const desiredTemplate =
    function cargoQuote(value) {
             makeOptions(menus);
         return "'" + String(value)
             .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }


        const desiredSignature =
    function cargoRows(res) {
            desiredTemplate
        return (res.cargoquery || []).map(
                .map(function (option) {
            function (row) {
                    return (
                 return row.title || {};
                        option.value +
            }
                        '::' +
        );
                        option.textContent
    }
                    );
                })
                 .join('||');


         applying = true;
    function getRealMenuSelects() {
         return [
            ...document.querySelectorAll(
                MENU_SELECTOR
            )
        ].filter(function (select) {
            return !select.name.includes('[num]');
        });
    }


        getRealMenuSelects().forEach(
    function resolveStallId(stallName) {
            function (select) {


                const previousValue =
        return api.get({
                    select.value;
            action: 'cargoquery',
 
            format: 'json',
                 /*
            tables: 'Stalls',
                * すでに正しい候補なら
            fields:
                * DOMを触らない
                 'stall_id=stall_id,' +
                */
                'name=name',
                 if (
            where:
                    optionSignature(select) ===
                'name=' +
                    desiredSignature
                 cargoQuote(stallName),
                ) {
            limit: 20
                    if (clearSelection &&
        }).then(function (res) {
                        select.value !== '') {
 
            const rows =
                cargoRows(res);


                        select.value = '';
            if (rows.length === 1) {
                return rows[0].stall_id;
            }


                        if (window.jQuery) {
            /*
                            jQuery(select)
            * 同名表示が
                                .trigger('change');
            * 名前 (ID)
                        }
            * になっている場合
                    }
            */
            const match =
                String(stallName)
                    .match(/\((\d+)\)$/);


                     return;
            if (!match) {
                }
                throw new Error(
                    '屋台を1件に特定できません: ' +
                     stallName
                );
            }


                const newOptions =
            return match[1];
                    desiredTemplate.map(
        });
                        function (option) {
    }
                            return option
                                .cloneNode(true);
                        }
                    );


                select.replaceChildren(
    function loadMenus(stallId) {
                    ...newOptions
                );


                if (!clearSelection) {
        const key =
            String(stallId);


                    const exists =
        if (menuCache[key]) {
                        [...select.options]
            return Promise.resolve(
                            .some(
                menuCache[key]
                                function (option) {
            );
                                    return (
        }
                                        option.value ===
                                        previousValue
                                    );
                                }
                            );


                    if (exists) {
        return api.get({
                        select.value =
            action: 'cargoquery',
                            previousValue;
            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) {


                 if (clearSelection) {
            const rows =
                    select.value = '';
                 cargoRows(res);
                }


                if (window.jQuery) {
            menuCache[key] =
                    jQuery(select)
                 rows;
                        .trigger('change');
                 }
            }
        );


        /*
             return rows;
        * MutationObserverに
        });
        * 自分自身の変更を拾わせない
        */
        setTimeout(
             function () {
                applying = false;
            },
            0
        );
     }
     }


     function refreshMenus(
     function optionBelongsToMenu(
         clearSelection
         option,
        menu
     ) {
     ) {


         const stall =
         const name =
             document.querySelector(
             String(menu.name || '');
                 STALL_SELECTOR
 
        const id =
            String(
                 menu.menu_item_id || ''
             );
             );


if (!stall) {
        const value =
    return;
            String(option.value || '');
}


if (!stall.value) {
        const text =
    /*
            String(
    * 屋台が未選択なら、
                option.textContent || ''
    * 進行中の古い非同期処理を無効化し、
            );
    * 商品候補を空欄だけに戻す。
    */
    ++requestSerial;


    filterMenuSelects(
        /*
         [],
        * 商品名が一意
        true
        */
    );
        if (
            value === name ||
            text === name
         ) {
            return true;
        }


    return;
        /*
}
        * Page Formsによる
        * 同名商品の識別表示
        *
        * たこ焼き (1)
        * たこ焼き (3)
        */
        const mapped =
            name + ' (' + id + ')';


         const serial =
         return (
             ++requestSerial;
            value === mapped ||
             text === mapped
        );
    }


        const stallName =
    function makeOptions(menus) {
            stall.value;


         resolveStallId(
         const options = [];
            stallName
        )
        .then(function (stallId) {


             if (
        /*
                 serial !==
        * 空欄
                 requestSerial
        */
             ) {
        const blank =
                return null;
             masterOptions.find(
            }
                 function (option) {
                    return (
                        option.value === ''
                    );
                 }
             );


             console.log(
        if (blank) {
                 '[屋台→商品V2]',
             options.push(
                stallName,
                 blank.cloneNode(true)
                '→ stall_id=' +
            );
                stallId
        } else {
            options.push(
                new Option('', '')
             );
             );
        }


            return loadMenus(
        menus.forEach(
                stallId
             function (menu) {
             );


        })
                const option =
        .then(function (menus) {
                    masterOptions.find(
                        function (candidate) {
                            return optionBelongsToMenu(
                                candidate,
                                menu
                            );
                        }
                    );


            if (
                if (option) {
                !menus ||
                    options.push(
                 serial !==
                        option.cloneNode(true)
                     requestSerial
                    );
            ) {
                 } else {
                 return;
                     console.warn(
                        'Page Formsのoptionを特定できません:',
                        menu
                    );
                 }
             }
             }
        );


            console.log(
        return options;
                '[販売商品候補V2]',
    }
                menus
            );


            filterMenuSelects(
    function optionSignature(select) {
                menus,
                clearSelection
            );


         })
         return [...select.options]
        .catch(function (err) {
            .map(function (option) {
 
                return (
            console.error(
                    option.value +
                '[屋台→商品V2] エラー:',
                    '::' +
                 err
                    option.textContent
             );
                 );
        });
             })
            .join('||');
     }
     }


    /*
     function filterMenuSelects(
    * Page Formsによる
         menus,
    * option再生成を検出
        clearSelection
    */
     function mutationTouchesMenus(
         mutation
     ) {
     ) {


         const target =
         const desiredTemplate =
             mutation.target;
             makeOptions(menus);


         if (
         const desiredSignature =
            target.nodeType === 1 &&
             desiredTemplate
             target.matches &&
                .map(function (option) {
            target.matches(MENU_SELECTOR)
                    return (
        ) {
                        option.value +
            return true;
                        '::' +
        }
                        option.textContent
                    );
                })
                .join('||');


         for (
         applying = true;
            const node of
            mutation.addedNodes
        ) {


            if (
        getRealMenuSelects().forEach(
                node.nodeType !== 1
             function (select) {
             ) {
                continue;
            }


            if (
                 const previousValue =
                 node.matches &&
                    select.value;
                node.matches(MENU_SELECTOR)
            ) {
                return true;
            }


            if (
                /*
                node.querySelector &&
                * すでに正しい候補なら
                node.querySelector(
                * DOMを触らない
                     MENU_SELECTOR
                */
                 )
                if (
            ) {
                    optionSignature(select) ===
                return true;
                     desiredSignature
            }
                 ) {
                    if (clearSelection &&
                        select.value !== '') {


            /*
                        select.value = '';
            * SELECTの中にOPTIONが追加された
            */
            if (
                node.tagName === 'OPTION' &&
                node.parentElement &&
                node.parentElement.matches &&
                node.parentElement.matches(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }
        }


        return false;
                        if (window.jQuery) {
    }
                            jQuery(select)
                                .trigger('change');
                        }
                    }


    const observer =
        new MutationObserver(
            function (mutations) {
                if (applying) {
                     return;
                     return;
                 }
                 }


                 const touched =
                 const newOptions =
                     mutations.some(
                     desiredTemplate.map(
                         mutationTouchesMenus
                         function (option) {
                            return option
                                .cloneNode(true);
                        }
                     );
                     );


                 if (!touched) {
                 select.replaceChildren(
                     return;
                     ...newOptions
                 }
                 );


                 clearTimeout(
                 if (!clearSelection) {
                    observerTimer
                );


                /*
                    const exists =
                * Page Formsの再初期化が
                        [...select.options]
                * 完了してから実行
                            .some(
                */
                                function (option) {
                observerTimer =
                                    return (
                    setTimeout(
                                        option.value ===
                        function () {
                                        previousValue
                            refreshMenus(false);
                                    );
                        },
                                }
                        250
                            );
                    );
            }
        );


    const form =
                    if (exists) {
        document.getElementById(
                        select.value =
            'pfForm'
                            previousValue;
        ) || document.body;
                    }
                }


    observer.observe(
                if (clearSelection) {
        form,
                    select.value = '';
        {
                }
            childList: true,
            subtree: true
        }
    );


    /*
                if (window.jQuery) {
    * 屋台変更
                    jQuery(select)
    */
                        .trigger('change');
    const stall =
                }
        document.querySelector(
             }
             STALL_SELECTOR
         );
         );


    function onStallChange() {
        /*
         refreshMenus(true);
        * MutationObserverに
        * 自分自身の変更を拾わせない
        */
        setTimeout(
            function () {
                applying = false;
            },
            0
         );
     }
     }


         stall.addEventListener(
    function refreshMenus(
        'change',
         clearSelection
        onStallChange
    ) {
     );
 
        const stall =
            document.querySelector(
                STALL_SELECTOR
            );
 
if (!stall) {
     return;
}


if (!stall.value) {
     /*
     /*
     * 初期表示
     * 屋台が未選択なら、
    * 進行中の古い非同期処理を無効化し、
    * 商品候補を空欄だけに戻す。
     */
     */
     refreshMenus(false);
     ++requestSerial;


         console.log(
    filterMenuSelects(
         '屋台→販売商品連動を初期化しました。'
         [],
         true
     );
     );


});
    return;
}


/*
        const serial =
* FestivalStallMenuOffering
            ++requestSerial;
* 同一Placement内の商品重複警告
*
* 保存は禁止しない。
*/
(function () {
    'use strict';


    const MENU_SELECTOR =
        const stallName =
        'select[name^="FestivalStallMenuOffering["]' +
            stall.value;
        '[name$="[menu_item_id]"]';


    function setupOfferingDuplicateWarning() {
        resolveStallId(
         const form =
            stallName
            document.getElementById('pfForm');
        )
         .then(function (stallId) {


        if (!form) {
            if (
            return;
                serial !==
        }
                requestSerial
            ) {
                return null;
            }


        /*
             console.log(
        * FestivalStallPlacementフォームだけを対象にする。
                 '[屋台→商品V2]',
        */
                stallName,
        if (
                '→ stall_id=' +
             !form.querySelector(
                stallId
                 '[name="FestivalStallPlacement[stall_id]"]'
             );
            )
        ) {
             return;
        }


        function getMenuSelects() {
             return loadMenus(
             return [
                 stallId
                ...form.querySelectorAll(
             );
                    MENU_SELECTOR
                 )
             ].filter(function (select) {
                return !select.name.includes('[num]');
            });
        }


         function getWarning() {
         })
            let warning =
        .then(function (menus) {
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );


             if (warning) {
             if (
                 return warning;
                !menus ||
                serial !==
                    requestSerial
            ) {
                 return;
             }
             }


             const firstSelect =
             console.log(
                 getMenuSelects()[0];
                 '[販売商品候補V2]',
                menus
            );


             if (!firstSelect) {
             filterMenuSelects(
                 return null;
                 menus,
             }
                clearSelection
             );


            warning =
        })
                document.createElement('div');
        .catch(function (err) {


             warning.className =
             console.error(
                'stall-offering-duplicate-warning';
                 '[屋台→商品V2] エラー:',
 
                 err
            warning.setAttribute(
                 'role',
                 'status'
             );
             );
        });
    }


            warning.hidden = true;
    /*
 
    * Page Formsによる
            warning.style.marginTop = '8px';
    * option再生成を検出
            warning.style.padding = '10px';
    */
            warning.style.border =
    function mutationTouchesMenus(
                '1px solid #a2a9b1';
        mutation
            warning.style.borderRadius = '4px';
    ) {


/*
        const target =
* 警告は個別の商品行ではなく、
            mutation.target;
* 販売商品multiple全体の上部に表示する。
*/
const wrapper =
    firstSelect.closest(
        '.multipleTemplateWrapper'
    );


const list =
         if (
    wrapper
             target.nodeType === 1 &&
         ? wrapper.querySelector(
            target.matches &&
             '.multipleTemplateList'
            target.matches(MENU_SELECTOR)
        )
         ) {
        : null;
             return true;
 
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() {
         for (
             const warning =
             const node of
                form.querySelector(
            mutation.addedNodes
                    '.stall-offering-duplicate-warning'
        ) {
                );


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


             warning.hidden = true;
             if (
             warning.textContent = '';
                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;
            }
         }
         }


         function checkDuplicates() {
         return false;
            const selects =
    }
                getMenuSelects();
 
    const observer =
        new MutationObserver(
            function (mutations) {


            const counts = {};
                if (applying) {
                    return;
                }


            selects.forEach(function (select) {
                 const touched =
                 const value =
                     mutations.some(
                     String(
                         mutationTouchesMenus
                         select.value || ''
                     );
                     ).trim();


                 if (!value) {
                 if (!touched) {
                     return;
                     return;
                 }
                 }


                 counts[value] =
                 clearTimeout(
                    (counts[value] || 0) + 1;
                     observerTimer
            });
 
            const duplicates =
                Object.keys(counts).filter(
                    function (value) {
                        return counts[value] > 1;
                     }
                 );
                 );


            if (duplicates.length === 0) {
                /*
                clearWarning();
                * Page Formsの再初期化が
                return;
                * 完了してから実行
                */
                observerTimer =
                    setTimeout(
                        function () {
                            refreshMenus(false);
                        },
                        250
                    );
             }
             }
        );


            const warning =
    const form =
                getWarning();
        document.getElementById(
            'pfForm'
        ) || document.body;


            if (!warning) {
    observer.observe(
                return;
        form,
             }
        {
            childList: true,
             subtree: true
        }
    );


             warning.textContent = '';
    /*
    * 屋台変更
    */
    const stall =
        document.querySelector(
             STALL_SELECTOR
        );


            const title =
    function onStallChange() {
                document.createElement('strong');
        refreshMenus(true);
    }


            title.textContent =
        stall.addEventListener(
                '同じ販売商品が複数回選択されています。';
        'change',
        onStallChange
    );


            warning.appendChild(title);
    /*
    * 初期表示
    */
    refreshMenus(false);


            const detail =
        console.log(
                document.createElement('div');
        '屋台→販売商品連動を初期化しました。'
    );


            detail.textContent =
});
                duplicates.join('、') +
                ' が重複しています。' +
                '重複登録でないか確認してください。' +
                '保存自体は禁止しません。';


             warning.appendChild(detail);
/*
 
* FestivalStallMenuOffering
             warning.hidden = false;
* 同一Placement内の商品重複警告
*
* 保存は禁止しない。
*/
(function () {
    'use strict';
 
    const MENU_SELECTOR =
        'select[name^="FestivalStallMenuOffering["]' +
        '[name$="[menu_item_id]"]';
 
    function setupOfferingDuplicateWarning() {
        const form =
             document.getElementById('pfForm');
 
        if (!form) {
             return;
         }
         }


         /*
         /*
         * multipleで後から追加された行にも対応。
         * FestivalStallPlacementフォームだけを対象にする。
         */
         */
         if (
         if (
             form.dataset
             !form.querySelector(
                 .offeringDuplicateWarning !== '1'
                 '[name="FestivalStallPlacement[stall_id]"]'
            )
         ) {
         ) {
             form.dataset
             return;
                .offeringDuplicateWarning = '1';
        }


/*
         function getMenuSelects() {
* Page Forms / Select2 は
             return [
* jQueryのchangeを使う場合があるため、
                 ...form.querySelectorAll(
* 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
                     MENU_SELECTOR
                 )
                 )
             ) {
             ].filter(function (select) {
                 window.setTimeout(
                 return !select.name.includes('[num]');
                    checkDuplicates,
             });
                    0
                );
             }
         }
         }
    );
 
}
        function getWarning() {
 
             let warning =
             const observer =
                 form.querySelector(
                 new MutationObserver(
                     '.stall-offering-duplicate-warning'
                     function () {
                        window.setTimeout(
                            checkDuplicates,
                            0
                        );
                    }
                 );
                 );


             observer.observe(
             if (warning) {
                form,
                 return warning;
                {
             }
                    childList: true,
                    subtree: true
                 }
             );
        }


        checkDuplicates();
            const firstSelect =
    }
                getMenuSelects()[0];


    if (
            if (!firstSelect) {
        document.readyState === 'loading'
                return null;
    ) {
             }
        document.addEventListener(
             'DOMContentLoaded',
            setupOfferingDuplicateWarning
        );
    } else {
        setupOfferingDuplicateWarning();
    }


    mw.hook(
            warning =
        'wikipage.content'
                document.createElement('div');
    ).add(
        setupOfferingDuplicateWarning
    );


})();
            warning.className =
                'stall-offering-duplicate-warning';


/*
            warning.setAttribute(
* StallMenuItem
                'role',
* 入力検証・状態日本語化
                'status'
*/
            );
(function () {
    'use strict';


    function setupStallMenuItemValidation() {
            warning.hidden = true;
        const form = document.getElementById('pfForm');


         if (!form) {
            warning.style.marginTop = '8px';
             return;
            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) {
        * StallMenuItemフォーム以外では何もしない。
    list.insertAdjacentElement(
        */
        'beforebegin',
        const nameInput = form.querySelector(
         warning
            'input[name="StallMenuItem[name]"]'
    );
         );
} else {
    const container =
        firstSelect.closest('fieldset') ||
        firstSelect.closest('td') ||
         firstSelect.parentNode;


         if (!nameInput) {
    container.insertBefore(
            return;
        warning,
        }
         container.firstChild
    );
}


        /*
             return warning;
        * 二重初期化防止
        */
        if (
            form.dataset.stallMenuItemValidationInitialized === '1'
        ) {
             return;
         }
         }


         form.dataset.stallMenuItemValidationInitialized = '1';
         function clearWarning() {
            const warning =
                form.querySelector(
                    '.stall-offering-duplicate-warning'
                );


        /*
            if (!warning) {
        * =====================================
                return;
        * 状態を日本語表示
             }
        * =====================================
        */
        const statusLabels = {
            active: '取扱中',
            inactive: '一時停止',
             discontinued: '取扱終了',
            unknown: '未確認'
        };


        const statusSelect = form.querySelector(
            warning.hidden = true;
             'select[name="StallMenuItem[status]"]'
             warning.textContent = '';
         );
         }


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


            const counts = {};


        console.log(
            selects.forEach(function (select) {
            '商品マスター入力チェックを初期化しました。'
                const value =
        );
                    String(
    }
                        select.value || ''
                    ).trim();


    if (document.readyState === 'loading') {
                if (!value) {
        document.addEventListener(
                    return;
            'DOMContentLoaded',
                }
            setupStallMenuItemValidation
        );
    } else {
        setupStallMenuItemValidation();
    }


    mw.hook('wikipage.content').add(
                counts[value] =
        setupStallMenuItemValidation
                    (counts[value] || 0) + 1;
    );
            });


})();
            const duplicates =
                Object.keys(counts).filter(
                    function (value) {
                        return counts[value] > 1;
                    }
                );


/*
            if (duplicates.length === 0) {
* StallMenuItem
                clearWarning();
* 同一屋台 + 同一商品名の重複警告
                return;
*
            }
* 保存は禁止しない。
*/
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';


    const api = new mw.Api();
            const warning =
                getWarning();


    function cargoQuote(value) {
            if (!warning) {
        return "'" + String(value)
                return;
            .replace(/\\/g, '\\\\')
             }
             .replace(/'/g, "\\'") + "'";
    }


    function cargoRows(response) {
             warning.textContent = '';
        return (response.cargoquery || []).map(
             function (row) {
                return row.title || row;
            }
        );
    }


    function cargoQuery(
            const title =
        tables,
                document.createElement('strong');
        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) {
             title.textContent =
        return String(value || '')
                '同じ販売商品が複数回選択されています。';
             .replace(/_/g, ' ')
            .trim();
    }


    function setupStallMenuItemDuplicateWarning() {
             warning.appendChild(title);
        const form =
             document.getElementById('pfForm');


        if (!form) {
            const detail =
            return;
                document.createElement('div');
        }


        /*
            detail.textContent =
        * StallMenuItemフォームだけを対象にする。
                duplicates.join('、') +
        */
                ' が重複しています。' +
        const stallSelect = form.querySelector(
                '重複登録でないか確認してください。' +
            'select[name="StallMenuItem[stall_id]"]'
                '保存自体は禁止しません。';
        );


        const nameInput = form.querySelector(
            warning.appendChild(detail);
            'input[name="StallMenuItem[name]"]'
        );


        if (!stallSelect || !nameInput) {
             warning.hidden = false;
             return;
         }
         }


         /*
         /*
         * 二重初期化防止
         * multipleで後から追加された行にも対応。
         */
         */
         if (
         if (
             form.dataset
             form.dataset
                 .stallMenuItemDuplicateWarning ===
                 .offeringDuplicateWarning !== '1'
            '1'
         ) {
         ) {
             return;
             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
                );
            }
         }
         }
    );
}


        form.dataset
            const observer =
            .stallMenuItemDuplicateWarning =
                new MutationObserver(
            '1';
                    function () {
                        window.setTimeout(
                            checkDuplicates,
                            0
                        );
                    }
                );


        /*
            observer.observe(
        * 警告表示欄
                form,
        */
                {
        const warning =
                    childList: true,
             document.createElement('div');
                    subtree: true
                }
             );
        }


         warning.className =
         checkDuplicates();
            'stall-menu-item-duplicate-warning';
    }


         warning.setAttribute(
    if (
             'role',
        document.readyState === 'loading'
             'status'
    ) {
         document.addEventListener(
             'DOMContentLoaded',
             setupOfferingDuplicateWarning
         );
         );
    } else {
        setupOfferingDuplicateWarning();
    }


         warning.hidden = true;
    mw.hook(
         'wikipage.content'
    ).add(
        setupOfferingDuplicateWarning
    );


        warning.style.marginTop = '8px';
})();
        warning.style.padding = '10px';
        warning.style.border = '1px solid #a2a9b1';
        warning.style.borderRadius = '4px';


        const container =
/*
            nameInput.closest('td') ||
* StallMenuItem
            nameInput.parentNode;
* 入力検証・状態日本語化
*/
(function () {
    'use strict';


         container.appendChild(warning);
    function setupStallMenuItemValidation() {
         const form = document.getElementById('pfForm');


         let timer = null;
         if (!form) {
         let requestSerial = 0;
            return;
         }


         /*
         /*
         * Page Formsのmappingでは
         * StallMenuItemフォーム以外では何もしない。
        * SELECT.valueが屋台名になる場合があるため、
        * Cargoからstall_idを解決する。
         */
         */
         function resolveStallId() {
         const nameInput = form.querySelector(
            const rawValue =
            'input[name="StallMenuItem[name]"]'
                String(
        );
                    stallSelect.value || ''
                ).trim();


            if (!rawValue) {
        if (!nameInput) {
                return Promise.resolve('');
            return;
            }
        }


            /*
        /*
            * 数値ならそのまま使用。
        * 二重初期化防止
            */
        */
            if (/^\d+$/.test(rawValue)) {
        if (
                return Promise.resolve(
            form.dataset.stallMenuItemValidationInitialized === '1'
                    rawValue
        ) {
                );
            return;
            }
        }


            const selectedOption =
        form.dataset.stallMenuItemValidationInitialized = '1';
                stallSelect.options[
                    stallSelect.selectedIndex
                ];


            const selectedText =
        /*
                selectedOption
        * =====================================
                    ? selectedOption.textContent.trim()
        * 状態を日本語表示
                    : '';
        * =====================================
        */
        const statusLabels = {
            active: '取扱中',
            inactive: '一時停止',
            discontinued: '取扱終了',
            unknown: '未確認'
        };


             const names = [];
        const statusSelect = form.querySelector(
             'select[name="StallMenuItem[status]"]'
        );


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


            if (
                selectedText &&
                names.indexOf(selectedText) === -1
            ) {
                names.push(selectedText);
            }


            if (names.length === 0) {
        console.log(
                return Promise.resolve('');
            '商品マスター入力チェックを初期化しました。'
            }
        );
    }


            const where = names.map(
    if (document.readyState === 'loading') {
                function (name) {
        document.addEventListener(
                    return (
            'DOMContentLoaded',
                        'name=' +
            setupStallMenuItemValidation
                        cargoQuote(name)
        );
                    );
    } else {
                }
        setupStallMenuItemValidation();
            ).join(' OR ');
    }


            return cargoQuery(
    mw.hook('wikipage.content').add(
                'Stalls',
        setupStallMenuItemValidation
                '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;
* StallMenuItem
            warning.textContent = '';
* 同一屋台 + 同一商品名の重複警告
        }
*
* 保存は禁止しない。
*/
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';


        function showWarning(rows) {
    const api = new mw.Api();
            warning.textContent = '';


             const title =
    function cargoQuote(value) {
                document.createElement('strong');
        return "'" + String(value)
             .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }


             title.textContent =
    function cargoRows(response) {
                '同じ屋台に同名の商品がすでに登録されています。';
        return (response.cargoquery || []).map(
             function (row) {
                return row.title || row;
            }
        );
    }


             warning.appendChild(title);
    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);
            }
        );
    }


             const text =
    function normalizePageName(value) {
                document.createElement('div');
        return String(value || '')
             .replace(/_/g, ' ')
            .trim();
    }


             text.textContent =
    function setupStallMenuItemDuplicateWarning() {
                '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
        const form =
             document.getElementById('pfForm');


            warning.appendChild(text);
        if (!form) {
            return;
        }


            const list =
        /*
                document.createElement('ul');
        * StallMenuItemフォームだけを対象にする。
        */
        const stallSelect = form.querySelector(
            'select[name="StallMenuItem[stall_id]"]'
        );


            rows.forEach(
        const nameInput = form.querySelector(
                function (row) {
            'input[name="StallMenuItem[name]"]'
                    const item =
        );
                        document.createElement('li');


                    const link =
        if (!stallSelect || !nameInput) {
                        document.createElement('a');
            return;
        }


                    link.href =
        /*
                        mw.util.getUrl(
        * 二重初期化防止
                            row.page_name
        */
                        );
        if (
            form.dataset
                .stallMenuItemDuplicateWarning ===
            '1'
        ) {
            return;
        }


                    link.textContent =
        form.dataset
                        (
            .stallMenuItemDuplicateWarning =
                            row.menu_name ||
            '1';
                            '商品'
                        ) +
                        '(商品ID: ' +
                        row.menu_item_id +
                        '';


                    link.target = '_blank';
        /*
        * 警告表示欄
        */
        const warning =
            document.createElement('div');


                    item.appendChild(link);
        warning.className =
                    list.appendChild(item);
            'stall-menu-item-duplicate-warning';
                }
            );


            warning.appendChild(list);
        warning.setAttribute(
             warning.hidden = false;
             'role',
         }
            'status'
         );


         function checkDuplicate() {
         warning.hidden = true;
            const menuName =
                nameInput.value.trim();


            if (
        warning.style.marginTop = '8px';
                !stallSelect.value ||
        warning.style.padding = '10px';
                !menuName
        warning.style.border = '1px solid #a2a9b1';
            ) {
        warning.style.borderRadius = '4px';
                clearWarning();
                return;
            }


            const currentRequest =
        const container =
                ++requestSerial;
            nameInput.closest('td') ||
            nameInput.parentNode;


            resolveStallId().then(
        container.appendChild(warning);
                function (stallId) {
                    if (
                        currentRequest !==
                        requestSerial
                    ) {
                        return null;
                    }


                    if (!stallId) {
        let timer = null;
                        clearWarning();
        let requestSerial = 0;
                        return null;
                    }


                    return cargoQuery(
        /*
                        'StallMenuItems',
        * Page Formsのmappingでは
                        'menu_item_id=menu_item_id,' +
        * SELECT.valueが屋台名になる場合があるため、
                            'name=menu_name,' +
        * Cargoからstall_idを解決する。
                            '_pageName=page_name',
        */
                        'stall_id=' +
        function resolveStallId() {
                            stallId +
             const rawValue =
                            ' AND name=' +
                 String(
                            cargoQuote(
                     stallSelect.value || ''
                                menuName
                ).trim();
                            ),
                        20
                    );
                }
             ).then(
                 function (rows) {
                     if (
                        rows === null ||
                        rows === undefined
                    ) {
                        return;
                    }


                    if (
            if (!rawValue) {
                        currentRequest !==
                return Promise.resolve('');
                        requestSerial
            }
                    ) {
                        return;
                    }


                    /*
            /*
                    * 編集画面では
            * 数値ならそのまま使用。
                    * 自分自身を重複候補から除外。
            */
                    */
            if (/^\d+$/.test(rawValue)) {
                     const currentPage =
                return Promise.resolve(
                        normalizePageName(
                     rawValue
                            mw.config.get(
                );
                                'wgPageName'
            }
                            )
 
                        );
            const selectedOption =
                stallSelect.options[
                    stallSelect.selectedIndex
                ];


                    const duplicates =
            const selectedText =
                        rows.filter(
                selectedOption
                            function (row) {
                    ? selectedOption.textContent.trim()
                                return (
                    : '';
                                    normalizePageName(
                                        row.page_name
                                    ) !==
                                    currentPage
                                );
                            }
                        );


                    if (
            const names = [];
                        duplicates.length === 0
                    ) {
                        clearWarning();
                        return;
                    }


                    showWarning(
             if (rawValue) {
                        duplicates
                names.push(rawValue);
                    );
            }
                }
             ).catch(
                function (error) {
                    console.error(
                        '商品重複確認に失敗しました。',
                        error
                    );


                    clearWarning();
            if (
                 }
                 selectedText &&
             );
                names.indexOf(selectedText) === -1
        }
             ) {
                names.push(selectedText);
            }


        function scheduleCheck() {
            if (names.length === 0) {
            window.clearTimeout(timer);
                return Promise.resolve('');
            }


             timer =
             const where = names.map(
                 window.setTimeout(
                function (name) {
                     checkDuplicate,
                    return (
                     300
                        '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 = '';
         }
         }


         stallSelect.addEventListener(
         function showWarning(rows) {
             'change',
             warning.textContent = '';
            scheduleCheck
        );


        nameInput.addEventListener(
            const title =
            'input',
                document.createElement('strong');
            scheduleCheck
        );


        nameInput.addEventListener(
            title.textContent =
            'change',
                '同じ屋台に同名の商品がすでに登録されています。';
            scheduleCheck
        );


        /*
            warning.appendChild(title);
        * 編集画面で既存値が入っている場合にも確認。
        */
        scheduleCheck();


        console.log(
            const text =
            '商品重複警告を初期化しました。'
                document.createElement('div');
        );
    }


    if (
            text.textContent =
        document.readyState ===
                '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemDuplicateWarning
        );
    } else {
        setupStallMenuItemDuplicateWarning();
    }


    mw.hook(
            warning.appendChild(text);
        'wikipage.content'
    ).add(
        setupStallMenuItemDuplicateWarning
    );


});
            const list =
                document.createElement('ul');


/* =========================================
            rows.forEach(
* Venue:緯度・経度バリデーション
                function (row) {
* ========================================= */
                    const item =
$(function () {
                        document.createElement('li');
    const latitudeInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );


    const longitudeInput = document.querySelector(
                    const link =
        'input[name="Venue[longitude]"]'
                        document.createElement('a');
    );


    function setupVenueCoordinateValidation(
                    link.href =
        input,
                        mw.util.getUrl(
        label,
                            row.page_name
        min,
                        );
        max
    ) {
        if (!input) {
            return;
        }


        input.inputMode = 'decimal';
                    link.textContent =
                        (
                            row.menu_name ||
                            '商品'
                        ) +
                        '(商品ID: ' +
                        row.menu_item_id +
                        '';


        const validateCoordinate = function () {
                    link.target = '_blank';
            const value = input.value.trim();


            input.setCustomValidity('');
                    item.appendChild(link);
                    list.appendChild(item);
                }
            );


             /*
             warning.appendChild(list);
            * Venueでは緯度・経度は任意。
            warning.hidden = false;
            * 空欄なら正常。
        }
            */
 
            if (value === '') {
        function checkDuplicate() {
                 return;
            const menuName =
            }
                 nameInput.value.trim();


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


            /*
             const currentRequest =
            * 日本付近の範囲チェック
                ++requestSerial;
            */
             const number = Number(value);


             if (number < min || number > max) {
             resolveStallId().then(
                 input.setCustomValidity(
                 function (stallId) {
                     label +
                     if (
                    'は' +
                        currentRequest !==
                    min +
                        requestSerial
                     '〜' +
                     ) {
                    max +
                        return null;
                     'の範囲で入力してください。'
                     }
                );
            }
        };


        input.addEventListener(
                    if (!stallId) {
            'input',
                        clearWarning();
            validateCoordinate
                        return null;
        );
                    }


        input.addEventListener(
                    return cargoQuery(
            'change',
                        'StallMenuItems',
            validateCoordinate
                        'menu_item_id=menu_item_id,' +
        );
                            'name=menu_name,' +
 
                            '_pageName=page_name',
        input.addEventListener(
                        'stall_id=' +
            'invalid',
                            stallId +
            validateCoordinate
                            ' AND name=' +
        );
                            cargoQuote(
                                menuName
                            ),
                        20
                    );
                }
            ).then(
                function (rows) {
                    if (
                        rows === null ||
                        rows === undefined
                    ) {
                        return;
                    }


        validateCoordinate();
                    if (
    }
                        currentRequest !==
                        requestSerial
                    ) {
                        return;
                    }


    setupVenueCoordinateValidation(
                    /*
        latitudeInput,
                    * 編集画面では
        '緯度',
                    * 自分自身を重複候補から除外。
        20,
                    */
        46
                    const currentPage =
    );
                        normalizePageName(
                            mw.config.get(
                                'wgPageName'
                            )
                        );


    setupVenueCoordinateValidation(
                    const duplicates =
        longitudeInput,
                        rows.filter(
        '経度',
                            function (row) {
        122,
                                return (
        154
                                    normalizePageName(
    );
                                        row.page_name
});
                                    ) !==
                                    currentPage
                                );
                            }
                        );


/* =========================================
                    if (
* Venue:地図ピン → 緯度・経度
                        duplicates.length === 0
* ========================================= */
                    ) {
$(function () {
                        clearWarning();
    const latInput = document.querySelector(
                        return;
        'input[name="Venue[latitude]"]'
                    }
    );


    const lonInput = document.querySelector(
                    showWarning(
        'input[name="Venue[longitude]"]'
                        duplicates
    );
                    );
                }
            ).catch(
                function (error) {
                    console.error(
                        '商品重複確認に失敗しました。',
                        error
                    );


    if (!latInput || !lonInput) {
                    clearWarning();
        return;
                }
    }
             );
 
    mw.loader.using('ext.pageforms.leaflet').then(function () {
        if (
            document.getElementById(
                'matsuri-venue-location-map'
             )
        ) {
            return;
         }
         }


         const mapDiv = document.createElement('div');
         function scheduleCheck() {
        mapDiv.id = 'matsuri-venue-location-map';
            window.clearTimeout(timer);
        mapDiv.style.height = '400px';
        mapDiv.style.width = '100%';
        mapDiv.style.marginBottom = '8px';


        const help = document.createElement('div');
            timer =
        help.textContent =
                window.setTimeout(
            '地図をクリックして会場位置を指定してください。ピンはドラッグして微調整できます。';
                    checkDuplicate,
         help.style.marginBottom = '8px';
                    300
                );
         }


         const wrapper = document.createElement('div');
         stallSelect.addEventListener(
        wrapper.appendChild(help);
            'change',
         wrapper.appendChild(mapDiv);
            scheduleCheck
         );


         const latRow = latInput.closest('tr');
         nameInput.addEventListener(
            'input',
            scheduleCheck
        );


         if (!latRow || !latRow.parentNode) {
         nameInput.addEventListener(
             return;
             'change',
        }
             scheduleCheck
 
         );
        const mapRow = document.createElement('tr');
 
        const th = document.createElement('th');
        th.textContent = '会場位置を地図から選択';
 
        const td = document.createElement('td');
        td.appendChild(wrapper);
 
        mapRow.appendChild(th);
        mapRow.appendChild(td);
 
        latRow.parentNode.insertBefore(
            mapRow,
             latRow
         );
 
        const hasCoordinates =
            latInput.value.trim() !== '' &&
            lonInput.value.trim() !== '' &&
            !Number.isNaN(Number(latInput.value)) &&
            !Number.isNaN(Number(lonInput.value));


         /*
         /*
         * 既存座標があればそこを表示。
         * 編集画面で既存値が入っている場合にも確認。
        * 新規・座標未登録なら日本全体を表示。
         */
         */
         const initialLat = hasCoordinates
         scheduleCheck();
            ? Number(latInput.value)
            : 36.2048;


         const initialLon = hasCoordinates
         console.log(
             ? Number(lonInput.value)
             '商品重複警告を初期化しました。'
            : 138.2529;
        );
    }


         const map = L.map(mapDiv).setView(
    if (
             [initialLat, initialLon],
         document.readyState ===
             hasCoordinates ? 17 : 5
        'loading'
    ) {
        document.addEventListener(
             'DOMContentLoaded',
             setupStallMenuItemDuplicateWarning
         );
         );
    } else {
        setupStallMenuItemDuplicateWarning();
    }


        L.tileLayer(
    mw.hook(
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
        'wikipage.content'
            {
    ).add(
                maxZoom: 19,
        setupStallMenuItemDuplicateWarning
                attribution:
    );
                    '&copy; OpenStreetMap contributors'
            }
        ).addTo(map);


        let marker = null;
});


        function updateInputs(lat, lon) {
/* =========================================
            const latValue =
* Venue:緯度・経度バリデーション
                Number(lat).toFixed(6);
* ========================================= */
$(function () {
    const latitudeInput = document.querySelector(
        'input[name="Venue[latitude]"]'
    );
 
    const longitudeInput = document.querySelector(
        'input[name="Venue[longitude]"]'
    );


            const lonValue =
    function setupVenueCoordinateValidation(
                Number(lon).toFixed(6);
        input,
        label,
        min,
        max
    ) {
        if (!input) {
            return;
        }


            latInput.value = latValue;
        input.inputMode = 'decimal';
            lonInput.value = lonValue;


            latInput.dispatchEvent(
        const validateCoordinate = function () {
                new Event(
             const value = input.value.trim();
                    'input',
                    { bubbles: true }
                )
             );


             lonInput.dispatchEvent(
             input.setCustomValidity('');
                new Event(
                    'input',
                    { bubbles: true }
                )
            );


             latInput.dispatchEvent(
             /*
                new Event(
            * Venueでは緯度・経度自体は任意。
                    'change',
            * ただし片方だけの入力は禁止する。
                     { bubbles: true }
            */
                )
            if (value === '') {
            );
                const otherInput =
                     input === latitudeInput
                        ? longitudeInput
                        : latitudeInput;


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


        function placeMarker(latlng) {
                 return;
            if (marker) {
             }
                 marker.setLatLng(latlng);
             } else {
                marker = L.marker(
                    latlng,
                    {
                        draggable: true
                    }
                ).addTo(map);


                marker.on(
            /*
                    'dragend',
            * 数値形式チェック
                    function () {
            */
                        const position =
            if (!/^-?\d+(\.\d+)?$/.test(value)) {
                            marker.getLatLng();
                input.setCustomValidity(
 
                     label + 'は数値で入力してください。'
                        updateInputs(
                            position.lat,
                            position.lng
                        );
                     }
                 );
                 );
                return;
             }
             }


             updateInputs(
             /*
                latlng.lat,
            * 日本付近の範囲チェック
                latlng.lng
            */
             );
             const number = Number(value);
        }


        if (hasCoordinates) {
            if (number < min || number > max) {
            placeMarker({
                input.setCustomValidity(
                lat: initialLat,
                    label +
                lng: initialLon
                    'は' +
            });
                    min +
        }
                    '' +
 
                    max +
        map.on(
                     'の範囲で入力してください。'
            'click',
            function (event) {
                placeMarker(
                     event.latlng
                 );
                 );
             }
             }
        };
        input.addEventListener(
            'input',
            validateCoordinate
        );
        input.addEventListener(
            'change',
            validateCoordinate
         );
         );


         /*
         input.addEventListener(
        * 緯度・経度を手動修正した場合も
            'invalid',
        * ピンを同期する。
            validateCoordinate
        */
         );
         function syncMarkerFromInputs() {
            const lat =
                Number(latInput.value);


            const lon =
        validateCoordinate();
                Number(lonInput.value);


            if (
        return validateCoordinate;
                latInput.value.trim() === '' ||
    }
                lonInput.value.trim() === '' ||
                Number.isNaN(lat) ||
                Number.isNaN(lon)
            ) {
                return;
            }


            const latlng = {
    const validateVenueLatitude =
                lat: lat,
        setupVenueCoordinateValidation(
                lng: lon
            latitudeInput,
             };
            '緯度',
             20,
            46
        );


            if (marker) {
    const validateVenueLongitude =
                marker.setLatLng(latlng);
        setupVenueCoordinateValidation(
             } else {
             longitudeInput,
                marker = L.marker(
            '経度',
                    latlng,
            122,
                    {
            154
                        draggable: true
        );
                    }
                ).addTo(map);


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


                        updateInputs(
        latitudeInput.addEventListener(
                            position.lat,
            'change',
                            position.lng
            validateVenueLongitude
                        );
        );
                    }
    }
                );
            }


            map.setView(
    if (
                [lat, lon],
        longitudeInput &&
                17
        validateVenueLatitude
            );
    ) {
        }
         longitudeInput.addEventListener(
 
             'input',
         latInput.addEventListener(
             validateVenueLatitude
             'change',
             syncMarkerFromInputs
         );
         );


         lonInput.addEventListener(
         longitudeInput.addEventListener(
             'change',
             'change',
             syncMarkerFromInputs
             validateVenueLatitude
         );
         );
 
     }
        setTimeout(function () {
            map.invalidateSize();
        }, 100);
 
        console.log(
            'Venue地図ピン入力を初期化しました。'
        );
     });
});
});


/* =========================================
/* =========================================
  * FestivalStallPlacement:
  * Venue:地図ピン → 緯度・経度
* 会場連動地図ピン → 緯度・経度
  * ========================================= */
  * ========================================= */
$(function () {
$(function () {
    const venueSelect = document.querySelector(
        'select[name="FestivalStallPlacement[venue_id]"]'
    );
     const latInput = document.querySelector(
     const latInput = document.querySelector(
         'input[name="FestivalStallPlacement[latitude]"]'
         'input[name="Venue[latitude]"]'
     );
     );


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


     if (
     if (!latInput || !lonInput) {
        !venueSelect ||
        !latInput ||
        !lonInput
    ) {
         return;
         return;
     }
     }


     mw.loader.using(
     mw.loader.using('ext.pageforms.leaflet').then(function () {
        'ext.pageforms.leaflet'
            const venueMarkerImagePath =
    ).then(function () {
            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 (
         if (
             document.getElementById(
             document.getElementById(
                 'matsuri-placement-location-map'
                 'matsuri-venue-location-map'
             )
             )
         ) {
         ) {
10,530行目: 10,997行目:
         }
         }


        const api = new mw.Api();
         const mapDiv = document.createElement('div');
 
         mapDiv.id = 'matsuri-venue-location-map';
         const mapDiv =
            document.createElement('div');
 
         mapDiv.id =
            'matsuri-placement-location-map';
 
         mapDiv.style.height = '400px';
         mapDiv.style.height = '400px';
         mapDiv.style.width = '100%';
         mapDiv.style.width = '100%';
         mapDiv.style.marginBottom = '8px';
         mapDiv.style.marginBottom = '8px';


         const help =
         const status = document.createElement('div');
            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.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';


         help.style.marginBottom = '8px';
         clearButton.textContent =
            '位置情報をクリア';


         const wrapper =
         controls.appendChild(
             document.createElement('div');
             clearButton
        );


        const wrapper = document.createElement('div');
        wrapper.appendChild(status);
         wrapper.appendChild(help);
         wrapper.appendChild(help);
        wrapper.appendChild(controls);
         wrapper.appendChild(mapDiv);
         wrapper.appendChild(mapDiv);


         const latRow =
         const latRow = latInput.closest('tr');
            latInput.closest('tr');


         if (
         if (!latRow || !latRow.parentNode) {
            !latRow ||
            !latRow.parentNode
        ) {
             return;
             return;
         }
         }


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


         const th =
         const th = document.createElement('th');
            document.createElement('th');
         th.textContent = '会場位置を地図から選択';
 
         th.textContent =
            '出店位置を地図から選択';
 
        const td =
            document.createElement('td');


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


10,590行目: 11,062行目:
         );
         );


         /*
         function updateLocationUi() {
        * 初期状態は日本全体。
            const latText =
        *
                latInput.value.trim();
        * 既存Placementに座標がある場合は
 
        * 後でその位置へ移動する。
            const lonText =
        */
                lonInput.value.trim();
        const map = L.map(
 
             mapDiv
             if (
        ).setView(
                latText === '' &&
            [ 36.2048, 138.2529 ],
                lonText === ''
            5
            ) {
        );
                status.textContent =
                    '位置情報:未登録';
 
                clearButton.disabled = true;


        L.tileLayer(
                 return;
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
            {
                 maxZoom: 19,
                attribution:
                    '&copy; OpenStreetMap contributors'
             }
             }
        ).addTo(map);


        let marker = null;
            clearButton.disabled = false;
        let venueRequestId = 0;
 
            if (
                latText !== '' &&
                lonText !== ''
            ) {
                status.textContent =
                    '位置情報:座標あり';


        function dispatchInputEvents(input) {
                return;
             input.dispatchEvent(
             }
                new Event(
                    'input',
                    { bubbles: true }
                )
            );


             input.dispatchEvent(
             status.textContent =
                 new Event(
                 '位置情報:入力不完全';
                    'change',
                    { bubbles: true }
                )
            );
         }
         }


         function updateInputs(lat, lon) {
         clearButton.addEventListener(
            latInput.value =
            'click',
                Number(lat).toFixed(6);
            function () {
 
                if (
            lonInput.value =
                    latInput.value.trim() === '' &&
                 Number(lon).toFixed(6);
                    lonInput.value.trim() === ''
                 ) {
                    updateLocationUi();
                    return;
                }


            /*
                if (
            * 既存の必須・日本範囲チェックを
                    !window.confirm(
            * そのまま発火させる。
                        '緯度・経度をクリアします。よろしいですか?'
            */
                    )
            dispatchInputEvents(latInput);
                ) {
            dispatchInputEvents(lonInput);
                    return;
        }
                }


        function createMarker(latlng) {
                latInput.value = '';
            marker = L.marker(
                 lonInput.value = '';
                 latlng,
                {
                    draggable: true
                }
            ).addTo(map);


            marker.on(
                latInput.dispatchEvent(
                'dragend',
                    new Event(
                function () {
                        'input',
                     const position =
                        { bubbles: true }
                        marker.getLatLng();
                     )
                );


                     updateInputs(
                lonInput.dispatchEvent(
                         position.lat,
                     new Event(
                         position.lng
                         'input',
                     );
                         { bubbles: true }
                 }
                     )
            );
                 );
        }


        function placeMarker(latlng) {
                latInput.dispatchEvent(
            if (marker) {
                    new Event(
                marker.setLatLng(latlng);
                        'change',
            } else {
                        { bubbles: true }
                 createMarker(latlng);
                    )
            }
                 );


            updateInputs(
                lonInput.dispatchEvent(
                latlng.lat,
                    new Event(
                 latlng.lng
                        'change',
            );
                        { bubbles: true }
        }
                    )
                 );


        function removeMarker() {
                updateLocationUi();
            if (!marker) {
                return;
             }
             }
        );


            map.removeLayer(marker);
        latInput.addEventListener(
             marker = null;
             'input',
         }
            updateLocationUi
         );


         function clearCoordinates() {
         lonInput.addEventListener(
             latInput.value = '';
             'input',
             lonInput.value = '';
             updateLocationUi
        );


             dispatchInputEvents(latInput);
        latInput.addEventListener(
             dispatchInputEvents(lonInput);
             'change',
        }
             updateLocationUi
        );


         function getCurrentCoordinates() {
         lonInput.addEventListener(
             const lat =
             'change',
                Number(latInput.value);
            updateLocationUi
        );


            const lon =
        updateLocationUi();
                Number(lonInput.value);


             if (
        const hasCoordinates =
                latInput.value.trim() === '' ||
             latInput.value.trim() !== '' &&
                lonInput.value.trim() === '' ||
            lonInput.value.trim() !== '' &&
                Number.isNaN(lat) ||
            !Number.isNaN(Number(latInput.value)) &&
                Number.isNaN(lon)
             !Number.isNaN(Number(lonInput.value));
            ) {
                return null;
             }
 
            return {
                lat: lat,
                lng: lon
            };
        }
 
        function escapeCargoValue(value) {
            return String(value)
                .replace(
                    /'/g,
                    "''"
                );
        }


         /*
         /*
         * 選択されたVenueの座標へ
         * 既存座標があればそこを表示。
         * 地図だけ移動する。
         * 新規・座標未登録なら日本全体を表示。
        *
        * Placementのlatitude/longitudeには
        * コピーしない。
         */
         */
         function centerOnVenue() {
         const initialLat = hasCoordinates
            const venuePage =
            ? Number(latInput.value)
                venueSelect.value.trim();
            : 36.2048;


             if (venuePage === '') {
        const initialLon = hasCoordinates
                return;
             ? Number(lonInput.value)
            }
            : 138.2529;


            const currentRequest =
        const map = L.map(mapDiv).setView(
                ++venueRequestId;
            [initialLat, initialLon],
            hasCoordinates ? 17 : 5
        );


            api.get({
        L.tileLayer(
                action: 'cargoquery',
            'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
                format: 'json',
            {
                tables: 'Venues',
                 maxZoom: 19,
                 fields:
                 attribution:
                    'venue_id=venue_id,' +
                     '&copy; OpenStreetMap contributors'
                    '_pageName=page_name,' +
             }
                    'latitude=latitude,' +
        ).addTo(map);
                    'longitude=longitude',
                 where:
                     "_pageName='" +
                    escapeCargoValue(
                        venuePage
                    ) +
                    "'",
                limit: '1'
             }).then(function (data) {
                /*
                * 連続して会場を変更した場合、
                * 古いレスポンスを無視する。
                */
                if (
                    currentRequest !==
                    venueRequestId
                ) {
                    return;
                }


                const result =
        let marker = null;
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];


                if (result.length === 0) {
        function updateInputs(lat, lon) {
                    console.warn(
            const latValue =
                        '会場情報を取得できませんでした。',
                Number(lat).toFixed(6);
                        venuePage
                    );
                    return;
                }


                const row =
            const lonValue =
                    result[0].title ||
                Number(lon).toFixed(6);
                    result[0];


                const lat =
            latInput.value = latValue;
                    Number(row.latitude);
            lonInput.value = lonValue;


                 const lon =
            latInput.dispatchEvent(
                     Number(row.longitude);
                 new Event(
                    'input',
                     { bubbles: true }
                )
            );
 
            lonInput.dispatchEvent(
                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(
            lonInput.dispatchEvent(
                     [ lat, lon ],
                 new Event(
                     18
                     'change',
                 );
                     { bubbles: true }
                 )
            );
        }


                 console.log(
        function placeMarker(latlng) {
                     '会場位置へ地図を移動しました。',
            if (marker) {
                marker.setLatLng(latlng);
            } else {
                 marker = L.marker(
                     latlng,
                     {
                     {
                         venue: venuePage,
                         draggable: true,
                         latitude: lat,
                         icon: venueMarkerIcon
                         longitude: lon
                    }
                ).addTo(map);
 
                marker.on(
                    'dragend',
                    function () {
                        const position =
                            marker.getLatLng();
 
                        updateInputs(
                            position.lat,
                            position.lng
                         );
                     }
                     }
                 );
                 );
             }).catch(function (error) {
             }
                console.error(
 
                    '会場座標の取得に失敗しました。',
            updateInputs(
                    error
                latlng.lat,
                 );
                latlng.lng
            );
        }
 
        if (hasCoordinates) {
            placeMarker({
                lat: initialLat,
                 lng: initialLon
             });
             });
         }
         }
       


        /*
        * 地図クリック
        */
         map.on(
         map.on(
             'click',
             'click',
10,856行目: 11,295行目:


         /*
         /*
         * 手入力された場合もピンを同期。
         * 緯度・経度を手動修正した場合も
        * ピンを同期する。
         */
         */
         function syncMarkerFromInputs() {
         function syncMarkerFromInputs() {
             const coordinates =
             const lat =
                 getCurrentCoordinates();
                 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
                );


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


             if (marker) {
            /*
                 marker.setLatLng(
            * 片方のみ入力、または数値不正の場合は
                    coordinates
            * 地図上のピンを勝手に変更しない。
                 );
            */
             } else {
             if (
                 createMarker(
                 latText === '' ||
                    coordinates
                lonText === '' ||
                );
                Number.isNaN(lat) ||
                 Number.isNaN(lon)
             ) {
                 return;
             }
             }


             map.setView(
             const latlng = {
                [
                lat: lat,
                     coordinates.lat,
                lng: lon
                     coordinates.lng
            };
                 ],
 
                 18
            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
             );
             );
         }
         }
10,895行目: 11,391行目:
         );
         );


         /*
         setTimeout(function () {
        * 会場を変更した場合。
            map.invalidateSize();
        *
        }, 100);
        * 前の会場用の屋台座標を
 
        * 誤って残さないようクリアする。
        console.log(
        */
             'Venue地図ピン入力を初期化しました。'
        venueSelect.addEventListener(
            'change',
            function () {
                removeMarker();
                clearCoordinates();
                centerOnVenue();
             }
         );
         );
    });
});


        /*
/* =========================================
        * 編集時:
* FestivalStallPlacement:
        * 既存Placement座標を優先。
* 祭り → 会場候補連動
        *
* ========================================= */
        * 新規時:
$(function () {
        * Venue座標へ地図を移動。
    function setupFestivalVenueFilter() {
        */
         const festivalSelect =
         const initialCoordinates =
             document.querySelector(
             getCurrentCoordinates();
                'input[type="hidden"][name="FestivalStallPlacement[festival_id]"]'
            ) ||
            document.querySelector(
                'select[name="FestivalStallPlacement[festival_id]"]:not(.pfComboBox)'
            );


         if (initialCoordinates) {
         const venueSelect =
             createMarker(
             document.querySelector(
                 initialCoordinates
                 'select[name="FestivalStallPlacement[venue_id]"]'
             );
             );


            map.setView(
        if (!festivalSelect || !venueSelect) {
                [
             return;
                    initialCoordinates.lat,
                    initialCoordinates.lng
                ],
                18
            );
        } else {
             centerOnVenue();
         }
         }


         setTimeout(
         if (
             function () {
             venueSelect.dataset.r5FestivalVenueFilter ===
                map.invalidateSize();
             '1'
             },
        ) {
             100
             return;
         );
         }


         console.log(
         venueSelect.dataset.r5FestivalVenueFilter =
             '出店位置地図ピン入力を初期化しました。'
             '1';
        );
    });
});


/* =========================================
        const api = new mw.Api();
* Venue:公式サイトURLの形式チェック
* ========================================= */
$(function () {
    const officialSiteInput = document.querySelector(
        'input[name="Venue[official_site]"]'
    );


    if (!officialSiteInput) {
        const originalOptions =
        return;
            Array.from(
    }
                venueSelect.options
            ).map(function (option) {
                return option.cloneNode(true);
            });


    officialSiteInput.inputMode = 'url';
        const initialFestival =
            festivalSelect.value.trim();


    const validateVenueOfficialSite = function () {
        const initialVenue =
        const value = officialSiteInput.value.trim();
            venueSelect.value.trim();


         officialSiteInput.setCustomValidity('');
         let requestId = 0;


         /*
         function escapeCargoValue(value) {
        * 空欄は許可。
            return String(value).replace(
        */
                /'/g,
        if (value === '') {
                "''"
             return;
             );
         }
         }


         try {
         function getBlankOption(label) {
             const url = new URL(value);
             let blank =
                originalOptions.find(function (option) {
                    return option.value === '';
                });


            /*
             if (blank) {
            * http:// または https:// のみ許可。
                 blank=blank.cloneNode(true);
            */
             } else {
             if (
                 blank=document.createElement(
                 url.protocol !== 'http:' &&
                     'option'
                url.protocol !== 'https:'
                 );
             ) {
                blank.value='';
                 officialSiteInput.setCustomValidity(
                     '公式サイトURLは http:// または https:// で始まるURLを入力してください。'
                 );
             }
             }
        } catch (e) {
 
             officialSiteInput.setCustomValidity(
             blank.textContent=label;
                '公式サイトURLを正しいURL形式で入力してください。'
 
             );
             return blank;
         }
         }
    };


    officialSiteInput.addEventListener(
        function findOriginalOption(value) {
        'input',
            const option =
        validateVenueOfficialSite
                originalOptions.find(
    );
                    function (item) {
                        return item.value === value;
                    }
                );


    officialSiteInput.addEventListener(
            if (option) {
        'change',
                return option.cloneNode(true);
        validateVenueOfficialSite
            }
    );


    officialSiteInput.addEventListener(
            const dynamicOption =
        'invalid',
                document.createElement(
        validateVenueOfficialSite
                    'option'
    );
                );


    validateVenueOfficialSite();
            dynamicOption.value =
});
                value;


/*
            dynamicOption.textContent =
* Festival
                value;
* 公式URLの形式チェック
*
* 空欄は許可。
* 入力された場合は http:// または https:// のURLのみ許可する。
*/
(function () {
    const fields = [
        'official_site',
        'official_x',
        'official_instagram',
        'official_facebook',
        'official_youtube'
    ];


    fields.forEach(function (fieldName) {
            dynamicOption.setAttribute(
        const input = document.querySelector(
                'data-r14-dynamic-venue-option',
            'input[name="Festival[' + fieldName + ']"]'
                '1'
        );
            );


        if (!input) {
             return dynamicOption;
             return;
         }
         }


         input.inputMode = 'url';
         function dispatchVenueChange(
 
            preservePlacementCoordinates
         const validateFestivalUrl = function () {
        ) {
             const value = input.value.trim();
            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;
                }


            input.setCustomValidity('');
                option.selected=false;
                fragment.appendChild(option);
            });


             if (value === '') {
             if (
                 return;
                preserveCurrent &&
            }
                oldValue !== '' &&
                !venuePages.includes(oldValue)
            ) {
                 const currentOption =
                    findOriginalOption(oldValue);


            try {
                if (currentOption) {
                const url = new URL(value);
                    currentOption.textContent +=
                        '(現在登録値)';


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


        input.addEventListener('input', validateFestivalUrl);
            venueSelect.replaceChildren(
        input.addEventListener('change', validateFestivalUrl);
                fragment
        input.addEventListener('invalid', validateFestivalUrl);
            );
    });
})();


/* =========================================
            let nextValue='';
* FestivalType:slug形式チェック
* ========================================= */
$(function () {
    const slugInput = document.querySelector(
        'input[name="FestivalType[slug]"]'
    );


    if (!slugInput) {
            if (
        return;
                preserveCurrent &&
    }
                oldValue !== '' &&
                Array.from(
                    venueSelect.options
                ).some(function (option) {
                    return option.value ===
                        oldValue;
                })
            ) {
                nextValue=oldValue;
            }


    slugInput.spellcheck = false;
            venueSelect.value=nextValue;
            venueSelect.disabled=false;


    const validateFestivalTypeSlug = function () {
            dispatchVenueChange(
         const value = slugInput.value.trim();
                preservePlacementCoordinates
            );
         }


         slugInput.setCustomValidity('');
         function showLoading() {
            venueSelect.replaceChildren(
                getBlankOption(
                    '会場候補を読み込み中…'
                )
            );


        /*
            venueSelect.disabled=true;
        * 空欄の必須チェックは
        * Page Forms の mandatory に任せる。
        */
        if (value === '') {
            return;
         }
         }


         /*
         function showFailure(
        * 英小文字・数字を基本とし、
            preserveCurrent,
        * 単語の区切りに半角ハイフンのみ許可する。
             preservePlacementCoordinates
        *
        * 先頭・末尾のハイフン、
        * 連続ハイフンは許可しない。
        */
        if (
             !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
         ) {
         ) {
             slugInput.setCustomValidity(
             const fragment =
                 'slugは英小文字・数字・半角ハイフンで入力してください。ハイフンは単語の区切りにのみ使用できます。'
                document.createDocumentFragment();
 
            fragment.appendChild(
                 getBlankOption(
                    '未指定(候補取得失敗)'
                )
             );
             );
        }
    };


    slugInput.addEventListener(
            if (
        'input',
                preserveCurrent &&
        validateFestivalTypeSlug
                initialVenue !== ''
    );
            ) {
                const current =
                    findOriginalOption(
                        initialVenue
                    );


    slugInput.addEventListener(
                if (current) {
        'change',
                    current.textContent +=
        validateFestivalTypeSlug
                        '(現在登録値)';
    );


    slugInput.addEventListener(
                    current.selected=true;
        'invalid',
        validateFestivalTypeSlug
    );


    validateFestivalTypeSlug();
                    fragment.appendChild(
});
                        current
                    );
                }
            }


/* =========================================
            venueSelect.replaceChildren(
* FestivalType:
                fragment
* 上位分類の自己参照・循環参照チェック
            );
* ========================================= */
$(function () {
    const nameInput = document.querySelector(
        'input[name="FestivalType[name]"]'
    );


    const parentSelect = document.querySelector(
            venueSelect.disabled=false;
        'select[name="FestivalType[parent_id]"]'
    );


    if (!nameInput || !parentSelect) {
            dispatchVenueChange(
         return;
                preservePlacementCoordinates
    }
            );
         }


    const api = new mw.Api();
        function loadVenues(
            preserveCurrent,
            preservePlacementCoordinates
        ) {
            const festivalValue =
                festivalSelect.value.trim();


    /*
            const currentRequest =
    * 既存ページでは page_id = type_id。
                ++requestId;
    * 新規作成時は 0 なので循環チェック対象外。
    */
    const currentTypeId = Number(
        mw.config.get('wgArticleId')
    );


    /*
            if (festivalValue === '') {
    * 編集途中で分類名を変更しても
                venueSelect.replaceChildren(
    * 自己判定できるよう、元の分類名も保持する。
                    getBlankOption('未指定')
    */
                );
    const originalName =
        nameInput.value.trim();


    let requestId = 0;
                venueSelect.disabled=false;


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


    async function getTypeByName(name) {
             showLoading();
        const data = await 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'
        });


        const rows =
            const escaped =
            Array.isArray(data.cargoquery)
                escapeCargoValue(
                ? data.cargoquery
                    festivalValue
                 : [];
                 );


        if (rows.length === 0) {
            api.get({
            return null;
                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;
                }


        return rows[0].title || rows[0];
                const rows =
    }
                    data &&
                    Array.isArray(
                        data.cargoquery
                    )
                        ? data.cargoquery
                        : [];


    async function getTypeById(typeId) {
                const venuePages=[];
        const data = await 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'
        });


        const rows =
                rows.forEach(function (result) {
            Array.isArray(data.cargoquery)
                    const row =
                ? data.cargoquery
                        result.title ||
                : [];
                        result;


        if (rows.length === 0) {
                    const page =
            return null;
                        row.venue_page ===
        }
                            undefined ||
                        row.venue_page ===
                            null
                            ? ''
                            : String(
                                row.venue_page
                            ).trim();


        return rows[0].title || rows[0];
                    if (
    }
                        page !== '' &&
                        !venuePages.includes(page)
                    ) {
                        venuePages.push(page);
                    }
                });


    async function validateFestivalTypeParent() {
                replaceOptions(
        const thisRequest = ++requestId;
                    venuePages,
                    preserveCurrent,
                    preservePlacementCoordinates
                );


        parentSelect.setCustomValidity('');
                console.log(
                    '祭り連動会場候補を更新しました。',
                    {
                        festival:
                            festivalValue,
                        venues:
                            venuePages
                    }
                );
            }).catch(function (error) {
                if (
                    currentRequest !==
                    requestId
                ) {
                    return;
                }


        const parentName =
                console.error(
            parentSelect.value.trim();
                    '祭り連動会場候補の取得に失敗しました。',
                    error
                );


        /*
                showFailure(
        * 親なしは正常。
                    preserveCurrent,
        */
                    preservePlacementCoordinates
        if (parentName === '') {
                );
             return;
             });
         }
         }


         /*
         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 (
         if (
             originalName !== '' &&
             !document.querySelector(
             parentName === originalName
                'select[name="FestivalStallPlacement[venue_id]"]'
             )
         ) {
         ) {
            parentSelect.setCustomValidity(
                '自分自身を上位分類に設定することはできません。'
            );
             return;
             return;
         }
         }


        /*
        * 新規ページでは、まだ自分自身のtype_idが存在しないので
        * 循環参照は発生しない。
        */
         if (
         if (
             !Number.isInteger(currentTypeId) ||
             festivalVenueFilterRetryTimer !==
             currentTypeId <= 0
             null
         ) {
         ) {
             return;
             return;
         }
         }


         try {
         function trySetup() {
             const selectedParent =
             var venueSelect;
                 await getTypeByName(parentName);
 
            festivalVenueFilterRetryTimer =
                 null;
 
            setupFestivalVenueFilter();


             /*
             venueSelect =
            * 途中で別の親へ変更された場合、
                document.querySelector(
            * 古いAPI結果を無視する。
                    'select[name="FestivalStallPlacement[venue_id]"]'
            */
                 );
            if (thisRequest !== requestId) {
                 return;
            }


             if (!selectedParent) {
             if (
                venueSelect &&
                venueSelect.getAttribute(
                    'data-r5-festival-venue-filter'
                ) === '1'
            ) {
                 return;
                 return;
             }
             }


             let typeId =
             attempts += 1;
                Number(selectedParent.type_id);


            /*
             if (attempts >= maxAttempts) {
            * IDでも自己参照を確認。
                 console.warn(
            */
                     '[R14-02] FestivalStallPlacement ' +
             if (typeId === currentTypeId) {
                    'festival/venue filter initialization timed out.'
                 parentSelect.setCustomValidity(
                     '自分自身を上位分類に設定することはできません。'
                 );
                 );
                 return;
                 return;
             }
             }


             let parentId =
             festivalVenueFilterRetryTimer =
                 selectedParent.parent_id;
                 window.setTimeout(
                    trySetup,
                    100
                );
        }
 
        trySetup();
    }


            const visited = new Set([
    startFestivalVenueFilterSetup();
                typeId
            ]);


            /*
    mw.hook(
            * 選択した親から上位へ順番に辿る。
        'pf.formSetupAfter'
            */
    ).add(
            while (
        startFestivalVenueFilterSetup
                parentId !== undefined &&
    );
                parentId !== null &&
});
                String(parentId).trim() !== ''
            ) {
                const numericParentId =
                    Number(parentId);


                /*
                * 自分自身へ戻ったら循環。
                */
                if (
                    numericParentId ===
                    currentTypeId
                ) {
                    parentSelect.setCustomValidity(
                        'この上位分類を設定すると分類階層が循環するため選択できません。'
                    );
                    return;
                }


                /*
/* =========================================
                * 既存データ側ですでに循環している場合も
* FestivalStallPlacement:
                * 無限ループを防ぐ。
* 会場連動地図ピン → 緯度・経度
                */
* ========================================= */
                if (
$(function () {
                    visited.has(
    const venueSelect = document.querySelector(
                        numericParentId
        'select[name="FestivalStallPlacement[venue_id]"]'
                    )
    );
                ) {
                    parentSelect.setCustomValidity(
                        '選択した上位分類の階層に循環があります。'
                    );
                    return;
                }


                visited.add(
    const latInput = document.querySelector(
                    numericParentId
        'input[name="FestivalStallPlacement[latitude]"]'
                );
    );


                const row =
    const lonInput = document.querySelector(
                    await getTypeById(
        'input[name="FestivalStallPlacement[longitude]"]'
                        numericParentId
    );
                    );


                if (thisRequest !== requestId) {
    if (
                    return;
        !venueSelect ||
                }
        !latInput ||
        !lonInput
    ) {
        return;
    }


                if (!row) {
    mw.loader.using(
                    return;
        'ext.pageforms.leaflet'
                }
    ).then(function () {
 
         if (
                parentId =
             document.getElementById(
                    row.parent_id;
                 'matsuri-placement-location-map'
            }
            )
         } catch (error) {
        ) {
             console.error(
             return;
                 '祭り分類の上位分類チェックに失敗しました。',
                error
             );
         }
         }
    }


    parentSelect.addEventListener(
        const api = new mw.Api();
        'change',
        validateFestivalTypeParent
    );


    /*
        const mapDiv =
    * Page Formsから送信しようとした場合にも
            document.createElement('div');
    * 現在の同期エラーを維持する。
    */
    parentSelect.addEventListener(
        'invalid',
        function () {
            validateFestivalTypeParent();
        }
    );


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

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 === */