編集の要約なし
安全な画像アップロードでファイル名の警告理由を表示
 
(3人の利用者による、間の59版が非表示)
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,175行目: 2,269行目:




     return (
    if (
        festivalMapMarkerIndexReady
    ) {
        scheduleFestivalMapInitialViewport();
    }
 
 
     return (
         festivalMapMarkerIndexReady
         festivalMapMarkerIndexReady
     );
     );
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;


            return true;
        }


            const marker =
                item.marker;


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




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


            return false;
        }


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


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


                     markerLayer.addLayer(
                     festivalMapInitialViewportTimer =
                         marker
                         null;
                    );


                 }
                    scheduleFestivalMapInitialViewport();
                 },
                100
            );




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


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


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


                }


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


        }
    );


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




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


function scheduleMapMarkerIndex() {
                festivalMapInitialViewportTimer =
                    null;


    if (
        festivalMapMarkerIndexReady
    ) {


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


        return;
                    festivalMapInitialViewportAttempts +=
    }
                        1;




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


                        return;
                    }


    function tryIndex() {


        mapIndexTimer =
                    scheduleFestivalMapInitialViewport();
            null;


                    return;
                }


        if (
            buildFestivalMapMarkerIndex()
        ) {


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


            return;
                    return;
        }
                }




        mapIndexAttempts +=
                festivalMapInitialViewportAttempts +=
            1;
                    1;




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


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


            return;
        }


                scheduleFestivalMapInitialViewport();
            },
            delay
        );


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


    return true;
}
}




/* =====================================
function applyFestivalMapInitialViewport() {
* 一覧の検索結果を
* 地図へ反映
* ===================================== */


function syncMapMarkers(
    if (
     visiblePlacementIds
        festivalMapInitialViewportApplied ||
) {
        !festivalMapMarkerIndexReady
     ) {
    pendingVisiblePlacementIds =
         return false;
         visiblePlacementIds
    }
            .map(
                String
            );




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


        applyMapMarkerFilter(
            pendingVisiblePlacementIds
        );


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




     /*
     /*
     * Maps側がまだ初期化されていれば待つ
     * mapPlacementIdsが存在する状態で
    * itemsが0件なのは初期化途中。
    *
    * applied=trueにはせず、
    * schedulerへfalseを返して再試行させる。
     */
     */
     scheduleMapMarkerIndex();
     if (
        items.length === 0
    ) {
        return false;
    }


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


function openPlacementOnMap(
    const map =
    placementId
        items[
) {
            0
 
        ].markerLayer &&
    const id =
        items[
         String(
            0
             placementId ||
         ].markerLayer._map
             ''
             ? items[
        );
                0
             ].markerLayer._map
            : null;




     if (
     if (
         !/^\d+$/.test(
         !map ||
             id
        typeof map.setView !==
        ) ||
             'function' ||
         id === '0'
         typeof map.fitBounds !==
            'function'
     ) {
     ) {
         return false;
         return false;
2,429行目: 2,615行目:


     /*
     /*
     * marker index未完成なら
     * 同一Festival地図に属するmarkerだけを
     * 一度構築を試す
     * viewport計算へ使用。
     */
     */
     if (
     const latLngs =
        !festivalMapMarkerIndex[
        items
             id
            .filter(
        ]
                function ( item ) {
    ) {
                    return (
 
                        item.markerLayer &&
        buildFestivalMapMarkerIndex();
                        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;
     }
     }
    const item =
        festivalMapMarkerIndex[
            id
        ];




     if (
     if (
         !item ||
         latLngs.length === 1
        !item.marker
     ) {
     ) {


         console.warn(
         map.setView(
             '地図markerが見つかりません:',
             latLngs[
             id
                0
            ],
            17,
            {
                animate:
                    false
             }
         );
         );


        return false;
    } else {


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


                maxZoom:
                    17,


    const marker =
                animate:
         item.marker;
                    false
            }
         );


    }


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


        item.markerLayer.addLayer(
    festivalMapInitialViewportApplied =
            marker
         true;
         );


     }
     return true;
}




    /*
/* =====================================
    * 地図までスクロール
* marker表示状態を変更
    */
* ===================================== */
    if (
        item.mapElement &&
        typeof item.mapElement
            .scrollIntoView ===
            'function'
    ) {


        item.mapElement.scrollIntoView(
function applyMapMarkerFilter(
            {
    visiblePlacementIds
                behavior:
) {
                    'smooth',


                 block:
    const visibleIds =
                    'center'
        new Set(
             }
            visiblePlacementIds.map(
                 String
             )
         );
         );


    }


    Object.keys(
        festivalMapMarkerIndex
    ).forEach(
        function ( placementId ) {
            const item =
                festivalMapMarkerIndex[
                    placementId
                ];


    /*
    * 少し待って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
function createMapViewButtons() {
                    .hasLayer ===
                    'function'
                    ? markerLayer.hasLayer(
                        marker
                    )
                    : true;


    cards.forEach(
        function ( card ) {


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


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


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


                }


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


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


            const wrapper =
                 if (
                 document.createElement(
                     isShown &&
                     'div'
                    typeof markerLayer
                 );
                        .removeLayer ===
                        'function'
                 ) {


            wrapper.className =
                    markerLayer.removeLayer(
                 'festival-stall-map-view';
                        marker
                    );
 
                 }


            }


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


            button.type =
}
                'button';


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


            button.dataset.placementId =
/* =====================================
                placementId;
* 地図初期化待ち
* ===================================== */


            button.textContent =
function scheduleMapMarkerIndex() {
                '地図で見る';


    if (
        festivalMapMarkerIndexReady
    ) {


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


        return;
    }


            wrapper.appendChild(
                button
            );


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


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


    function tryIndex() {


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


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


             } else {
        if (
             buildFestivalMapMarkerIndex()
        ) {


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


             }
             return;
        }


        }
    );


}
        mapIndexAttempts +=
            1;


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


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


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


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




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


    }


        const opened =
            openPlacementOnMap(
                placementId
            );


    tryIndex();


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


            scheduleMapMarkerIndex();


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


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


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


    if (
        buildFestivalMapMarkerIndex()
    ) {


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


                    button.disabled =
        return;
                        false;
    }


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


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


                    openPlacementOnMap(
}
                        placementId
                    );
/* =====================================
* placement_idのmarkerを開く
* ===================================== */


                },
function openPlacementOnMap(
                500
    placementId
            );
) {
 
        }


    const id =
        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'
const categoryFilter =
                )
    createFilterSelect(
            ) {
        'カテゴリ',
                return;
        'festival-stall-category-filter',
            }
        'すべて'
    );




/*
            const placementId =
* 会場
                String(
*/
                    card.dataset
const venueFilter =
                        .placementId ||
    createFilterSelect(
                    ''
        '会場',
                );
        'festival-stall-venue-filter',
        'すべて'
    );




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




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




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


filterRow.className =
            wrapper.className =
    'festival-stall-search-filters';
                'festival-stall-map-view';




filterRow.appendChild(
            const button =
    categoryFilter.wrapper
                document.createElement(
);
                    'button'
                );


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


/*
            button.className =
* 絞り込みリセット
                'festival-stall-map-view-button';
* ===================================== */


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


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


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


resetButton.textContent =
            button.setAttribute(
    '絞り込みをリセット';
                'aria-label',
                'この屋台を地図で見る'
            );


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


resetButton.disabled =
            wrapper.appendChild(
    true;
                button
            );


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


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


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


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


noResults.className =
                compareControl.parentNode
    'festival-stall-search-empty';
                    .insertBefore(
                        wrapper,
                        compareControl
                            .nextSibling
                    );


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


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


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


    label.appendChild(
         }
         input
     );
     );


searchBox.appendChild(
}
    label
);


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


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


/*
        const button =
* リセット
            event.target.closest(
*/
                '.festival-stall-map-view-button'
searchBox.appendChild(
            );
    resetButton
);




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


searchBox.appendChild(
    noResults
);


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


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


        const opened =
            openPlacementOnMap(
                placementId
            );


if (
    searchAnchor
) {


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


} else {
            scheduleMapMarkerIndex();


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


}
            button.disabled =
                true;


    /* =====================================
            button.textContent =
    * カードごとの検索文字列
                '地図を準備中…';
    *
    * 最初はカード本文だけ
    * ===================================== */


const searchIndex = {};


            window.setTimeout(
                function () {


/*
                    button.disabled =
* select候補
                        false;
*/
const categoryOptions =
    new Map();


const venueOptions =
                    button.textContent =
    new Map();
                        '地図で見る';




cards.forEach(
                    openPlacementOnMap(
    function ( card ) {
                        placementId
                    );


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


        }


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




        const venueName =
    /* =====================================
            String(
    * 検索UI
                card.dataset
    * ===================================== */
                    .venueName ||
                ''
            ).trim();


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


        const normalizedCategory =
    searchBox.className =
            normalizeSearchText(
        'festival-stall-search';
                category
            );




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


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


        /*
    label.textContent =
        * placementごとの検索情報
         '屋台を検索';
        */
        searchIndex[
            placementId
         ] = {


            text:
                normalizeSearchText(
                    card.textContent
                ),


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


            venueName:
    input.type =
                normalizedVenue
        'search';


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


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


        /*
    input.setAttribute(
        * カテゴリselect候補
        'autocomplete',
        */
        'off'
        if (
    );
            normalizedCategory &&
            !categoryOptions.has(
                normalizedCategory
            )
        ) {


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


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


function createFilterSelect(
    labelText,
    className,
    allText
) {


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


            venueOptions.set(
                normalizedVenue,
                venueName
            );


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


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


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


function fillFilterOptions(
    select,
    optionMap
) {


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


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


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


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


         }
    allOption.value =
     );
         '';
 
     allOption.textContent =
        allText;




     options.forEach(
     select.appendChild(
         function ( optionData ) {
         allOption
    );


            const value =
                optionData[
                    0
                ];


            const label =
    wrapper.appendChild(
                optionData[
        title
                    1
    );
                ];


    wrapper.appendChild(
        select
    );


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


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


             option.textContent =
        select:
                label;
             select
    };


}


            select.appendChild(
                option
            );


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


}


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


fillFilterOptions(
    categorySelect,
    categoryOptions
);


const categorySelect =
    categoryFilter.select;


fillFilterOptions(
    venueSelect,
    venueOptions
);


    /* =====================================
const venueSelect =
    * 件数表示
    venueFilter.select;
    * ===================================== */


    function updateCount(
        visible
    ) {


        count.textContent =
/*
            '表示:' +
* フィルター行
            visible +
*/
            ' / ' +
const filterRow =
            cards.length +
    document.createElement(
            '';
        'div'
    );


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




    updateCount(
filterRow.appendChild(
        cards.length
    categoryFilter.wrapper
    );
);


filterRow.appendChild(
    venueFilter.wrapper
);


    /* =====================================
/*
    * 検索実行
* 絞り込みリセット
    * ===================================== */
* ===================================== */


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


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


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


    /*
resetButton.textContent =
    * カテゴリ
     '絞り込みをリセット';
    */
     const selectedCategory =
        categorySelect.value;


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


    /*
resetButton.disabled =
    * 会場
     true;
    */
     const selectedVenue =
        venueSelect.value;


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


let visible =
    count.className =
    0;
        'festival-stall-search-count';


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


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


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


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


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


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


            const index =
    label.appendChild(
                searchIndex[
        input
                    placementId
    );
                ] || {


                    text:
searchBox.appendChild(
                        '',
    label
);


                    category:
searchBox.appendChild(
                        '',
    filterRow
);


                    venueName:
                        ''


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




            /* =============================
searchBox.appendChild(
            * フリーワード
    count
            * ============================= */
);


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




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


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


if (
    searchAnchor
) {


            /* =============================
    searchAnchor.appendChild(
            * 会場
        searchBox
            * ============================= */
    );


            const venueMatched =
} else {
                !selectedVenue ||
                index.venueName ===
                    selectedVenue;


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


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


            const matched =
    /* =====================================
                keywordMatched &&
    * カードごとの検索文字列
                categoryMatched &&
    *
                venueMatched;
    * 最初はカード本文だけ
    * ===================================== */


const searchIndex = {};


if (
    matched
) {


    card.style.display =
/*
        '';
* select候補
*/
const categoryOptions =
    new Map();


    visible +=
const venueOptions =
        1;
    new Map();




    /*
cards.forEach(
    * 地図にも残す
     function ( card ) {
    */
    visiblePlacementIds.push(
        placementId
     );


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


                card.style.display =
                    'none';


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


        }
    );


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


updateCount(
    visible
);


        const normalizedCategory =
            normalizeSearchText(
                category
            );


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


        const normalizedVenue =
            normalizeSearchText(
                venueName
            );


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


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


/*
            text:
* 地図を一覧と同期
                normalizeSearchText(
*/
                    card.textContent
syncMapMarkers(
                ),
    visiblePlacementIds
);


            category:
                normalizedCategory,


}
            venueName:
                normalizedVenue


        };


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


/*
        /*
* 初期状態
        * カテゴリselect候補
*
        */
* 最初は全placementを表示
        if (
*/
            normalizedCategory &&
syncMapMarkers(
            !categoryOptions.has(
    placementIds
                normalizedCategory
);
            )
        ) {
 
            categoryOptions.set(
                normalizedCategory,
                category
            );
 
        }




    input.addEventListener(
        /*
        'input',
        * 会場select候補
         applySearch
        */
    );
        if (
            normalizedVenue &&
            !venueOptions.has(
                normalizedVenue
            )
         ) {


categorySelect.addEventListener(
            venueOptions.set(
    'change',
                normalizedVenue,
    applySearch
                venueName
);
            );


        }


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


/* =====================================
/* =====================================
  * 絞り込みをすべてリセット
  * select option生成
  * ===================================== */
  * ===================================== */


resetButton.addEventListener(
function fillFilterOptions(
     'click',
     select,
     function () {
     optionMap
) {


        /*
    const options =
        * フリーワード
         Array.from(
        */
             optionMap.entries()
         input.value =
        );
             '';




        /*
    /*
        * カテゴリ
    * 表示名で並び替え
        */
    */
        categorySelect.value =
    options.sort(
            '';
        function ( a, b ) {


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


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




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


            const value =
                optionData[
                    0
                ];


        /*
            const label =
        * 続けて検索しやすくする
                optionData[
        */
                    1
        input.focus();
                ];


    }
);


    /* =====================================
            const option =
    * Placement → Offering取得
                document.createElement(
    * ===================================== */
                    'option'
                );


    cargoQuery(
            option.value =
                value;


        'FestivalStallMenuOfferings',
            option.textContent =
                label;


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


        'placement_id IN (' +
            select.appendChild(
        placementIds.join(
                option
             ','
             );
        ) +
        ')'


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


}


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


                                    return String(
fillFilterOptions(
                                        offering
    categorySelect,
                                            .menu_item_id ||
    categoryOptions
                                        ''
);
                                    );


                                }
                            )
                            .filter(
                                function ( id ) {


                                    return /^\d+$/.test(
fillFilterOptions(
                                        id
    venueSelect,
                                    );
    venueOptions
);


                                }
    /* =====================================
                            )
    * 件数表示
                    )
    * ===================================== */
                ];


    function updateCount(
        visible
    ) {


             /*
        count.textContent =
            * メニューが1件も無い
             '表示:' +
            */
            visible +
             if (
            ' / ' +
                menuItemIds.length === 0
             cards.length +
             ) {
             '件';


                return {
    }
                    offerings:
                        offerings,


                    menus:
                        []
                };


            }
    updateCount(
        cards.length
    );




            /* =================================
    /* =====================================
            * MenuItem名取得
    * 検索実行
            * ================================= */
    * ===================================== */


            return cargoQuery(
function applySearch() {


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


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


                'menu_item_id IN (' +
    /*
                menuItemIds.join(
    * カテゴリ
                    ','
    */
                ) +
    const selectedCategory =
                ')'
        categorySelect.value;


            ).then(
                function ( menus ) {


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


                        offerings:
                            offerings,


                        menus:
let visible =
                            menus
    0;


                    };


                }
/*
            );
* 地図に残すplacement_id
 
*/
        }
const visiblePlacementIds =
     ).then(
     [];
        function ( data ) {


            if (
                !data
            ) {
                return;
            }


cards.forEach(
        function ( card ) {


             /* =================================
             const placementId =
            * menu_item_id → 商品名
                String(
            * ================================= */
                    card.dataset
                        .placementId ||
                    ''
                );


            const menuNameMap =
                {};


            const index =
                searchIndex[
                    placementId
                ] || {


            data.menus.forEach(
                    text:
                function ( menu ) {
                        '',


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


                }
                    venueName:
            );
                        ''


                };


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


             const placementMenus =
             /* =============================
                {};
            * フリーワード
            * ============================= */


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


            data.offerings.forEach(
                function ( offering ) {


                    const placementId =
            /* =============================
                        String(
            * カテゴリ
                            offering
            * ============================= */
                                .placement_id ||
                            ''
                        );


                    const menuItemId =
            const categoryMatched =
                        String(
                !selectedCategory ||
                            offering
                index.category ===
                                .menu_item_id ||
                    selectedCategory;
                            ''
                        );




                    const menuName =
            /* =============================
                        menuNameMap[
            * 会場
                            menuItemId
            * ============================= */
                        ] || '';


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


                    if (
                        !menuName
                    ) {
                        return;
                    }


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


                    if (
            const matched =
                        !placementMenus[
                keywordMatched &&
                            placementId
                categoryMatched &&
                        ]
                venueMatched;
                    ) {


                        placementMenus[
                            placementId
                        ] = [];


                    }
if (
    matched
) {


    card.style.display =
        '';


                    placementMenus[
    visible +=
                        placementId
        1;
                    ].push(
                        menuName
                    );


                }
            );


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


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


            cards.forEach(
                card.style.display =
                function ( card ) {
                    'none';


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


        }
    );


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


updateCount(
    visible
);


if (
    searchIndex[
        placementId
    ]
) {


    searchIndex[
/*
        placementId
* 0件メッセージ
    ].text =
*/
        normalizeSearchText(
noResults.hidden =
            (
    visible !== 0;
                searchIndex[
                    placementId
                ].text ||
                ''
            ) +
            ' ' +
            menuNames.join(
                ' '
            )
        );


}


                }
/*
            );
* 検索条件が1つでもあれば
 
* リセットボタンを有効化
 
*/
            /*
resetButton.disabled =
            * 商品データ取得後、
    (
            * 入力済み検索を再判定
        normalizeSearchText(
            */
             input.value
             applySearch();
        ) === '' &&
        categorySelect.value === '' &&
        venueSelect.value === ''
    );


        }
    ).catch(
        function ( error ) {


            /*
/*
            * 商品データ取得に失敗しても
* 地図を一覧と同期
            * 屋台名検索は使えるようにする
*/
            */
syncMapMarkers(
            console.error(
    visiblePlacementIds
                '屋台商品検索データ取得エラー:',
);
                error
            );


        }
    );


} );
}


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


mw.loader.using( [
/*
    'mediawiki.storage',
* 各カードへ
    'mediawiki.api',
* 地図で見るボタン
    'mediawiki.util'
*/
] ).then( function () {
createMapViewButtons();


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




     const compareRoot =
     input.addEventListener(
        document.getElementById(
        'input',
            'stall-compare-page'
         applySearch
         );
    );


categorySelect.addEventListener(
    'change',
    applySearch
);


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


venueSelect.addEventListener(
    'change',
    applySearch
);


    const STORAGE_KEY =
/* =====================================
        'matsuriWikiComparePlacements';
* 絞り込みをすべてリセット
* ===================================== */


     const MIN_COMPARE = 2;
resetButton.addEventListener(
     const MAX_COMPARE = 4;
     'click',
     function () {


    const api =
        /*
         new mw.Api();
        * フリーワード
        */
         input.value =
            '';




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


    function getPlacementIds() {


         const raw =
         /*
            mw.storage.get(
        * 会場
                STORAGE_KEY
        */
             );
        venueSelect.value =
             '';




         if ( !raw ) {
         /*
            return [];
        * 一覧・件数・0件表示・
        }
        * 地図markerをすべて再計算
        */
        applySearch();




         try {
         /*
 
        * 続けて検索しやすくする
            const ids =
        */
                JSON.parse(
        input.focus();
                    raw
                );


    }
);


            if (
    /* =====================================
                !Array.isArray(
    * Placement → Offering取得
                    ids
    * ===================================== */
                )
            ) {
                return [];
            }


    cargoQuery(


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


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


         } catch ( e ) {
         'placement_id IN (' +
        placementIds.join(
            ','
        ) +
        ')'


            return [];
    ).then(
        function ( offerings ) {


        }


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


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


    /* =====================================
                                }
    * Cargo
                            )
    * ===================================== */
                            .filter(
                                function ( id ) {
 
                                    return /^\d+$/.test(
                                        id
                                    );


    function cargoQuery(
                                }
        table,
                            )
        fields,
                    )
        where,
                ];
        limit
    ) {


        const params = {


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


            tables: table,
                return {
                    offerings:
                        offerings,


            fields: fields,
                    menus:
                        []
                };


             limit: limit || 100,
             }


            format: 'json'


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


            return cargoQuery(


        if ( where ) {
                'StallMenuItems',
            params.where = where;
        }


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


        return api.get(
                'menu_item_id IN (' +
            params
                menuItemIds.join(
        ).then(
                    ','
            function ( data ) {
                ) +
                ')'


                if (
            ).then(
                    !data ||
                 function ( menus ) {
                    !Array.isArray(
 
                        data.cargoquery
                     return {
                    )
 
                 ) {
                        offerings:
                     return [];
                            offerings,
                }


                        menus:
                            menus


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


                        return (
                }
                            item.title ||
            );
                            item
                        );


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


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


    }


            /* =================================
            * menu_item_id → 商品名
            * ================================= */
            const menuNameMap =
                {};


    function makeInClause( ids ) {


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


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


                }
            );


    function uniqueIds( values ) {


        return [
             /* =================================
             ...new Set(
            * placement_id → 商品名[]
                values
            * ================================= */
                    .map( String )
                    .filter(
                        function ( id ) {


                            return (
            const placementMenus =
                                id &&
                {};
                                /^\d+$/.test(
                                    id
                                )
                            );


                        }
                    )
            )
        ];


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


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


    function mapBy(
                    const menuItemId =
        rows,
                        String(
        key
                            offering
    ) {
                                .menu_item_id ||
                            ''
                        );


        const result = {};


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


        rows.forEach(
            function ( row ) {


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




                result[
                     if (
                     String(
                         !placementMenus[
                         row[ key ]
                            placementId
                     )
                        ]
                ] = row;
                     ) {
 
                        placementMenus[
                            placementId
                        ] = [];


            }
                    }
        );




        return result;
                    placementMenus[
                        placementId
                    ].push(
                        menuName
                    );


    }
                }
            );




    /* =====================================
            /* =================================
    * 表示ヘルパー
            * 商品名を検索インデックスへ追加
    * ===================================== */
            * ================================= */


    function textOrDash(
            cards.forEach(
        value
                function ( card ) {
    ) {


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


        return String(
            value
        );


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




function cleanNumber(
if (
     value
     searchIndex[
        placementId
    ]
) {
) {


     if (
     searchIndex[
         value === undefined ||
         placementId
         value === null ||
    ].text =
        String( value ).trim() === ''
         normalizeSearchText(
    ) {
            (
        return '';
                searchIndex[
    }
                    placementId
 
                ].text ||
    const number =
                ''
        Number(
            ) +
             value
            ' ' +
            menuNames.join(
                ' '
             )
         );
         );


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


    if (
                }
        Number.isInteger(
             );
             number
        )
    ) {


        return String(
            number
        );


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


     return String(
        }
         Math.round(
     ).catch(
            number * 100
         function ( error ) {
        ) / 100
    );


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


        }
    );


/* =====================================
} );
  * 比較計算用数値
 
  * ===================================== */
/* ========================================
  * 屋台比較ページ
* placement_id 正式版
  * ======================================== */
 
mw.loader.using( [
    'mediawiki.storage',
    'mediawiki.api',
    'mediawiki.util'
] ).then( function () {


function toFiniteNumber(
     'use strict';
     value
) {


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


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


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


    return number;


}
    const STORAGE_KEY =
        'matsuriWikiComparePlacements';


     function formatHours(
     const MIN_COMPARE = 2;
        placement
     const MAX_COMPARE = 4;
     ) {


         if ( !placement ) {
    const api =
            return '―';
         new mw.Api();
        }




        const open =
    /* =====================================
            placement.opening_time || '';
    * localStorage
    * ===================================== */


        const close =
    function getPlacementIds() {
            placement.closing_time || '';


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


        if (
            open &&
            close
        ) {
            return (
                open +
                '~' +
                close
            );


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




         if ( open ) {
         try {


             return (
             const ids =
                open +
                JSON.parse(
                 '~'
                    raw
            );
                 );


        }


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


        if ( close ) {


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


        }


        } catch ( e ) {


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


         }
         }


    }


        return '未確認';


     }
     /* =====================================
    * Cargo
    * ===================================== */
 
    function cargoQuery(
        table,
        fields,
        where,
        limit
    ) {
 
        const params = {


            action: 'cargoquery',


    function formatPositionStatus(
            tables: table,
        status
    ) {


        switch ( status ) {
            fields: fields,


             case 'exact':
             limit: limit || 100,
                return '正確な位置';


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


            case 'test':
        };
                return 'テスト位置';


            default:
                return '位置未確認';


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


    }


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


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


        switch ( status ) {


            case 'verified':
                 return data.cargoquery.map(
                 return '確認済み';
                    function ( item ) {


            case 'partially_verified':
                        return (
                return '一部確認済み';
                            item.title ||
                            item
                        );


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


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


     }
     }




     function formatAvailability(
     function makeInClause( ids ) {
        status
    ) {


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


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


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


            default:
    function uniqueIds( values ) {
                return '未確認';


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


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


                        }
                    )
            )
        ];


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


function getUnitPrice(
    offering
) {


     const price =
     function mapBy(
         toFiniteNumber(
         rows,
            offering.price
        key
        );
    ) {


    const quantity =
        const result = {};
        toFiniteNumber(
            offering.serving_quantity
        );




    if (
        rows.forEach(
        price === null ||
            function ( row ) {
        quantity === null ||
        quantity <= 0
    ) {


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


    }


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


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




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


    }


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


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


    function textOrDash(
        value
    ) {


/* =====================================
        if (
* 単位価格
            value === undefined ||
* 比較計算用
            value === null ||
* ===================================== */
            value === ''
        ) {
            return '―';
        }


function getUnitPriceValue(
        return String(
     offering
            value
        );
 
    }
 
 
function cleanNumber(
     value
) {
) {


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


     const quantity =
     const number =
         toFiniteNumber(
         Number(
             offering.serving_quantity
             value
         );
         );


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


     if (
     if (
         price === null ||
         Number.isInteger(
        quantity === null ||
            number
         quantity <= 0
         )
     ) {
     ) {


         return null;
         return String(
            number
        );


     }
     }


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


4,336行目: 4,616行目:




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


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


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


         cell.textContent =
    const number =
             text;
         Number(
 
             value
         return cell;
         );


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


    return number;


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


     function createMenuList(
     function formatHours(
         menus
         placement
     ) {
     ) {


         const container =
        if ( !placement ) {
             document.createElement(
            return '―';
                'div'
        }
            );
 
 
         const open =
             placement.opening_time || '';


         container.className =
         const close =
             'stall-compare-menu-list';
             placement.closing_time || '';




         if (
         if (
             !menus ||
             open &&
             menus.length === 0
             close
         ) {
         ) {


             container.textContent =
             return (
                 'メニュー未登録';
                open +
 
                 '' +
             return container;
                close
             );


         }
         }




         menus.forEach(
         if ( open ) {
            function ( item ) {


                 const menu =
            return (
                    document.createElement(
                 open +
                        'div'
                ''
                    );
            );


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




                const name =
        if ( close ) {
                    document.createElement(
                        'strong'
                    );


                 name.className =
            return (
                    'stall-compare-menu-name';
                 '' +
                close
            );


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




const price =
        if (
    document.createElement(
            placement.hours_note
         'div'
         ) {
    );


price.className =
            return placement.hours_note;
    'stall-compare-menu-price';


        }


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


        return '未確認';


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


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


     badge.className =
     function formatPositionStatus(
         'stall-compare-best-badge ' +
         status
        'stall-compare-best-price';
    ) {


    badge.textContent =
         switch ( status ) {
         '最安価格';


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


     price.appendChild(
case 'approximate':
        document.createTextNode(
     return 'おおよその位置';
            ' '
 
        )
default:
     );
     return '位置未確認';


    price.appendChild(
         }
         badge
    );


}
    }




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


        switch ( status ) {


                if (
            case 'verified':
                    item.servingQuantity
                 return '確認済み';
                 ) {


                    serving.textContent =
            case 'partially_verified':
                        '内容量:' +
                return '一部確認済み';
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );


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


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


                }
        }


    }


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


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


        switch ( status ) {


unit.textContent =
            case 'available':
    '1単位あたり:' +
                return '販売あり';
    item.unitPrice;


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


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


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


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


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


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


    unit.appendChild(
function getUnitPrice(
        document.createTextNode(
    offering
            ' '
) {
        )
    );


     unit.appendChild(
     const price =
         badge
        toFiniteNumber(
    );
            offering.price
         );


}
    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );




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


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


    }


                menu.appendChild(
                    name
                );


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


                menu.appendChild(
                    serving
                );


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


                menu.appendChild(
                    availability
                );


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


                container.appendChild(
}
                    menu
                );


            }
        );


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


        return container;
function getUnitPriceValue(
    offering
) {


     }
     const price =
        toFiniteNumber(
            offering.price
        );


    const quantity =
        toFiniteNumber(
            offering.serving_quantity
        );


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


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


         compareRoot.innerHTML =
         return null;
            '';
 
    }




         const heading =
    return (
            document.createElement(
         price /
                'h2'
        quantity
            );
    );


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




        compareRoot.appendChild(
    /* =====================================
            heading
    * DOM
        );
    * ===================================== */


    function createTextCell(
        tagName,
        text
    ) {


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


         wrapper.className =
         cell.textContent =
             'stall-compare-table-wrapper';
             text;


        return cell;


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


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


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


         /* ------------------------------
    function createMenuList(
        * thead
         menus
        * ------------------------------ */
    ) {


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


         const headerRow =
         container.className =
             document.createElement(
             'stall-compare-menu-list';
                'tr'
            );




         headerRow.appendChild(
         if (
             createTextCell(
            !menus ||
                 'th',
            menus.length === 0
                '比較項目'
        ) {
             )
 
         );
             container.textContent =
                 'メニュー未登録';
 
             return container;
 
         }




         compareData.forEach(
         menus.forEach(
             function ( data ) {
             function ( item ) {


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


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


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


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


                    link.href =
                name.className =
                        mw.util.getUrl(
                    'stall-compare-menu-name';
                            data.stall
                                .page_name
                        );


                    link.textContent =
                name.textContent =
                        data.stall
                    item.menuName ||
                            .stall_name ||
                    '商品';
                        '屋台';




                    th.appendChild(
const price =
                        link
    document.createElement(
                    );
        'div'
    );


                } else {
price.className =
    'stall-compare-menu-price';


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


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


                headerRow.appendChild(
                    th
                );


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


 
    const badge =
         thead.appendChild(
         document.createElement(
             headerRow
             'span'
         );
         );


        table.appendChild(
    badge.className =
            thead
        'stall-compare-best-badge ' +
         );
         'stall-compare-best-price';


    badge.textContent =
        '最安価格';


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


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


        function addRow(
    price.appendChild(
            label,
        badge
            getter
    );
        ) {


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




            const labelCell =
                const serving =
                createTextCell(
                    document.createElement(
                    'th',
                        'div'
                     label
                     );
                );


            labelCell.scope =
                'row';


                if (
                    item.servingQuantity
                ) {


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


                } else {


            compareData.forEach(
                     serving.textContent =
                function ( data ) {
                         '内容量:未確認';
 
                     tr.appendChild(
                         createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );


                 }
                 }
            );




            tbody.appendChild(
const unit =
                tr
    document.createElement(
            );
        'div'
    );


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




        /* =================================
unit.textContent =
        * Placement情報
    '1単位あたり:' +
        * ================================= */
    item.unitPrice;


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


                return data.placement
/*
                    ? data.placement.year +
* 最安単位価格
                      '年'
*/
                    : '―';
if (
    item.isLowestUnitPrice
) {


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


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


         addRow(
    badge.textContent =
            '祭り',
         '最安単位価格';
            function ( data ) {


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


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


    unit.appendChild(
        badge
    );


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


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


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


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


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


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


            }
                menu.appendChild(
        );
                    price
                );


                menu.appendChild(
                    serving
                );


        addRow(
                menu.appendChild(
            'カテゴリ',
                    unit
            function ( data ) {
                );


                 return data.stall
                 menu.appendChild(
                     ? data.stall
                     availability
                        .category
                );
                     : '―';
 
 
                container.appendChild(
                     menu
                );


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




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


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


            }
        );


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


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


                return formatHours(
        compareRoot.innerHTML =
                    data.placement
            '';
                );


            }
        );


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


         addRow(
         heading.textContent =
             '位置情報',
             '屋台比較';
            function ( data ) {


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


             }
        compareRoot.appendChild(
             heading
         );
         );




         addRow(
         const wrapper =
            '確認状態',
            document.createElement(
             function ( data ) {
                'div'
             );


                return data.placement
        wrapper.className =
                    ? formatVerification(
            'stall-compare-table-wrapper';
                        data.placement
 
                            .verification_status
 
                    )
        const table =
                    : '';
            document.createElement(
                'table'
            );
 
        table.className =
            'stall-compare-table';


            }
        );


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


         /* =================================
         const thead =
        * メニュー
            document.createElement(
        * ================================= */
                'thead'
            );


         const menuRow =
         const headerRow =
             document.createElement(
             document.createElement(
                 'tr'
                 'tr'
4,899行目: 5,173行目:




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


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


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




                 td.appendChild(
                 if (
                     createMenuList(
                     data.stall &&
                        data.menus
                     data.stall.page_name
                     )
                 ) {
                 );


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


                 menuRow.appendChild(
                    link.href =
                     td
                        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
                 );
                 );


4,938行目: 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
                );


    function renderMessage(
            labelCell.scope =
        message
                'row';
    ) {


        compareRoot.innerHTML =
            '';


            tr.appendChild(
                labelCell
            );


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


        p.className =
            compareData.forEach(
            'stall-compare-page-message';
                function ( data ) {


        p.textContent =
                    tr.appendChild(
            message;
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );


                }
            );


        compareRoot.appendChild(
            p
        );


    }
            tbody.appendChild(
                tr
            );


        }


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


    const placementIds =
        /* =================================
        getPlacementIds();
        * Placement情報
        * ================================= */


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


    if (
                return data.placement
        placementIds.length <
                    ? data.placement.year +
        MIN_COMPARE
                      '年'
    ) {
                    : '―';


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


        return;


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


                return data.festival
                    ? data.festival
                        .festival_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,' +
        '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 (' +
                return data.venue
        makeInClause(
                    ? data.venue
            placementIds
                        .venue_name
        ) +
                    : '';
        ')',


         100
            }
         );


    ).then(
        function ( placements ) {


            const stallIds =
        addRow(
                uniqueIds(
            '地域',
                    placements.map(
            function ( data ) {
                        function ( row ) {
                            return row.stall_id;
                        }
                    )
                );


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


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


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


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


            /*
                return data.stall
            * STEP 2
                    ? data.stall
            */
                        .category
            return Promise.all( [
                    : '―';


                stallIds.length
            }
                    ? cargoQuery(
        );


                        'Stalls',


                        'stall_id=stall_id,' +
        addRow(
                        'name=stall_name,' +
            '出店場所',
                        'category=category,' +
            function ( data ) {
                        '_pageName=page_name',


                        'stall_id IN (' +
                return data.placement
                        makeInClause(
                    ? data.placement
                            stallIds
                         .location_note
                         ) +
                    : '';
                        ')',


                        100
            }
        );


                    )
                    : Promise.resolve(
                        []
                    ),


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


                 festivalIds.length
                 return formatHours(
                    ? cargoQuery(
                    data.placement
                );


                        'Festivals',
            }
        );


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


                        'festival_id IN (' +
        addRow(
                        makeInClause(
            '位置情報',
                            festivalIds
            function ( data ) {
                        ) +
                        ')',
 
                        100


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


            }
        );


                venueIds.length
                    ? cargoQuery(


                        'Venues',
        addRow(
 
            '確認状態',
                        'venue_id=venue_id,' +
            function ( data ) {
                        'name=venue_name,' +
                        'area_id=area_id,' +
                        '_pageName=page_name',
 
                        'venue_id IN (' +
                        makeInClause(
                            venueIds
                        ) +
                        ')',
 
                        100


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


            }
        );


                cargoQuery(


                    'FestivalStallMenuOfferings',
        /* =================================
        * メニュー
        * ================================= */


                    'placement_id=placement_id,' +
        const menuRow =
                    'menu_item_id=menu_item_id,' +
            document.createElement(
                    'price=price,' +
                'tr'
                    'serving_quantity=serving_quantity,' +
            );
                    'serving_unit=serving_unit,' +
                    'serving_note=serving_note,' +
                    'availability=availability,' +
                    'verification_status=verification_status,' +
                    'sort_order=sort_order',


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


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


                )
        menuLabel.scope =
            'row';


            ] ).then(
                function ( results ) {


                    return {
        menuRow.appendChild(
            menuLabel
        );


                        placements:
                            placements,


                        stalls:
        compareData.forEach(
                            results[ 0 ],
            function ( data ) {


                         festivals:
                const td =
                            results[ 1 ],
                    document.createElement(
                         'td'
                    );


                        venues:
                            results[ 2 ],


                         offerings:
                td.appendChild(
                            results[ 3 ]
                    createMenuList(
                         data.menus
                    )
                );


                    };


                 }
                 menuRow.appendChild(
            );
                    td
                );


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


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


        tbody.appendChild(
            menuRow
        );


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


        table.appendChild(
            tbody
        );


             /*
        wrapper.appendChild(
            * STEP 3
             table
            */
        );
            return Promise.all( [


                areaIds.length
        compareRoot.appendChild(
                    ? cargoQuery(
            wrapper
        );


                        'Areas',


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


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


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


                    )
                    : Promise.resolve(
                        []
                    ),


        compareRoot.appendChild(
            note
        );


                menuItemIds.length
    }
                    ? cargoQuery(


                        'StallMenuItems',


                        'menu_item_id=menu_item_id,' +
    function renderMessage(
                        'stall_id=stall_id,' +
        message
                        'name=menu_name,' +
    ) {
                        'item_category=item_category,' +
                        'sort_order=sort_order',


                        'menu_item_id IN (' +
        compareRoot.innerHTML =
                        makeInClause(
            '';
                            menuItemIds
                        ) +
                        ')',


                        100


                    )
        const p =
                    : Promise.resolve(
            document.createElement(
                        []
                'p'
                    )
            );


            ] ).then(
        p.className =
                function ( results ) {
            'stall-compare-page-message';


                    data.areas =
        p.textContent =
                        results[ 0 ];
            message;


                    data.menuItems =
                        results[ 1 ];


                    return data;
        compareRoot.appendChild(
            p
        );


                }
    }
            );


        }
    ).then(
        function ( data ) {


            const placementMap =
    /* =====================================
                mapBy(
    * データ取得
                    data.placements,
    * ===================================== */
                    'placement_id'
                );


    const placementIds =
        getPlacementIds();


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


    if (
        placementIds.length <
        MIN_COMPARE
    ) {


            const festivalMap =
        renderMessage(
                mapBy(
            '比較する出店を2件以上選択してください。'
                    data.festivals,
        );
                    'festival_id'
                );


        return;


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




            const areaMap =
    /*
                mapBy(
    * STEP 1
                    data.areas,
    * Placementを直接取得
                    'area_id'
    */
                );
    cargoQuery(


        'FestivalStallPlacements',


            const menuMap =
        'placement_id=placement_id,' +
                mapBy(
        'stall_id=stall_id,' +
                    data.menuItems,
        'festival_id=festival_id,' +
                    'menu_item_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
            * Offering
            * placement単位
            */
            const offeringsByPlacement =
                {};


    ).then(
        function ( placements ) {


             data.offerings
             const stallIds =
                 .slice()
                 uniqueIds(
                .sort(
                    placements.map(
                    function ( a, b ) {
                        function ( row ) {
                            return row.stall_id;
                        }
                    )
                );


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


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


                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );
                        if (
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {
                            offeringsByPlacement[
                                placementId
                            ] = [];


            const venueIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.venue_id;
                         }
                         }
 
                    )
 
                        offeringsByPlacement[
                            placementId
                        ].push(
                            offering
                        );
 
                    }
                 );
                 );




             /*
             /*
             * localStorage順を維持
             * STEP 2
             */
             */
             const compareData =
             return Promise.all( [
                 placementIds.map(
 
                     function (
                 stallIds.length
                         placementId
                     ? cargoQuery(
                    ) {
 
                         'Stalls',


                         const placement =
                         'stall_id=stall_id,' +
                            placementMap[
                        'name=stall_name,' +
                                placementId
                        'category=category,' +
                            ] || null;
                        '_pageName=page_name',


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


                         if ( !placement ) {
                         100


                            return {
                    )
                    : Promise.resolve(
                        []
                    ),


                                placementId:
                                    placementId,


                                placement:
                festivalIds.length
                                    null,
                    ? cargoQuery(


                                stall:
                        'Festivals',
                                    null,


                                festival:
                        'festival_id=festival_id,' +
                                    null,
                        'name=festival_name,' +
                        '_pageName=page_name',


                                venue:
                        'festival_id IN (' +
                                    null,
                        makeInClause(
                            festivalIds
                        ) +
                        ')',


                                area:
                        100
                                    null,


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


                            };


                        }
                venueIds.length
                    ? cargoQuery(


                        'Venues',


                         const stall =
                         'venue_id=venue_id,' +
                            stallMap[
                        'name=venue_name,' +
                                String(
                        'area_id=area_id,' +
                                    placement.stall_id
                        '_pageName=page_name',
                                )
                            ] || null;


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


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


                    )
                    : Promise.resolve(
                        []
                    ),


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


                cargoQuery(


                        let area = null;
                    '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',


                        if (
                    'placement_id IN (' +
                            venue &&
                    makeInClause(
                            venue.area_id
                        placementIds
                        ) {
                    ) +
                    ')',


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


                        }
                )


            ] ).then(
                function ( results ) {


                        const offerings =
                    return {
                            offeringsByPlacement[
 
                                placementId
                        placements:
                             ] || [];
                             placements,


                        stalls:
                            results[ 0 ],


/*
                        festivals:
* Placementに紐づくメニューを生成
                            results[ 1 ],
*/
const menus =
    offerings.map(
        function ( offering ) {


            const menu =
                        venues:
                menuMap[
                            results[ 2 ],
                    String(
                        offering.menu_item_id
                    )
                ] || {};


                        offerings:
                            results[ 3 ]


            return {
                    };


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


        }
    ).then(
        function ( data ) {


                 menuName:
            const areaIds =
                     menu.menu_name ||
                 uniqueIds(
                     '商品',
                     data.venues.map(
                        function ( row ) {
                            return row.area_id;
                        }
                     )
                );




                 category:
            const menuItemIds =
                     menu.item_category ||
                 uniqueIds(
                     '',
                     data.offerings.map(
                        function ( row ) {
                            return row.menu_item_id;
                        }
                     )
                );




                /*
            /*
                * 表示価格
            * STEP 3
                */
            */
                 price:
            return Promise.all( [
                     cleanNumber(
 
                         offering.price
                 areaIds.length
                    ),
                     ? cargoQuery(
 
                         'Areas',


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


                /*
                        'area_id IN (' +
                * 比較用価格
                        makeInClause(
                */
                            areaIds
                priceValue:
                        ) +
                    toFiniteNumber(
                         ')',
                         offering.price
                    ),


                        100


                servingQuantity:
                    )
                     cleanNumber(
                     : Promise.resolve(
                         offering
                         []
                            .serving_quantity
                     ),
                     ),




                 servingUnit:
                 menuItemIds.length
                    offering
                     ? cargoQuery(
                        .serving_unit ||
                     '',


                        'StallMenuItems',


                servingNote:
'menu_item_id=menu_item_id,' +
                    offering
'stall_id=stall_id,' +
                        .serving_note ||
'name=menu_name,' +
                    '',
'item_category=item_category',


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


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


                    )
                    : Promise.resolve(
                        []
                    )


                /*
            ] ).then(
                * 比較用単位価格
                 function ( results ) {
                */
                 unitPriceValue:
                    getUnitPriceValue(
                        offering
                    ),


                    data.areas =
                        results[ 0 ];


                availability:
                     data.menuItems =
                     formatAvailability(
                         results[ 1 ];
                         offering
                            .availability
                    ),


                    return data;


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


        }
    ).then(
        function ( data ) {


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




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


            };


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




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


    placementId:
        placementId,


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


    stall:
        stall,


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


    venue:
        venue,


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


    menus:
        menus


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


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


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


function markBestPrices(
                        const placementId =
    compareData
                            String(
) {
                                offering
                                    .placement_id
                            );


    const allMenus = [];


                        if (
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {


    /* =================================
                            offeringsByPlacement[
    * 比較文字列を正規化
                                placementId
    *
                            ] = [];
    * 例:
    * "たこ焼き"
    * " たこ焼き "
    *
    * を同じものとして扱う
    * ================================= */
 
    function normalizeCompareText(
        value
    ) {


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


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


        /*
                        offeringsByPlacement[
        * 全角・半角などを可能な範囲で統一
                            placementId
        */
                        ].push(
        if (
                            offering
            typeof text.normalize ===
                        );
            'function'
        ) {


            text =
                     }
                text.normalize(
                     'NFKC'
                 );
                 );


        }


        /*
            /*
        * 連続空白を1つにする
            * localStorage順を維持
        */
            */
        text =
            const compareData =
            text.replace(
                placementIds.map(
                /\s+/g,
                    function (
                ' '
                        placementId
            );
                    ) {


        /*
                        const placement =
        * 英字商品名にも対応
                            placementMap[
        */
                                placementId
        text =
                            ] || null;
            text.toLowerCase();


        return text;


    }
                        if ( !placement ) {


                            return {


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


    compareData.forEach(
                                placement:
        function ( data ) {
                                    null,


            if (
                                stall:
                !data.menus ||
                                    null,
                !Array.isArray(
                    data.menus
                )
            ) {
                return;
            }


                                festival:
                                    null,


            data.menus.forEach(
                                venue:
                function ( menu ) {
                                    null,


                    /*
                                area:
                    * 毎回初期化
                                    null,
                    */
                    menu.isLowestPrice =
                        false;


                    menu.isLowestUnitPrice =
                                menus:
                        false;
                                    []


                            };


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




                    /*
                        const stall =
                    * 比較用単位
                            stallMap[
                    */
                                String(
                    menu.compareUnit =
                                    placement.stall_id
                        normalizeCompareText(
                                )
                             menu.servingUnit
                             ] || null;
                        );




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


                }
            );


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




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


    const groups = {};


                        if (
                            venue &&
                            venue.area_id
                        ) {


    allMenus.forEach(
                            area =
        function ( menu ) {
                                areaMap[
                                    String(
                                        venue.area_id
                                    )
                                ] || null;


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




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




            const groupKey =
/*
                menu.compareMenuName +
* Placementに紐づくメニューを生成
                '||' +
*/
                menu.compareUnit;
const menus =
    offerings.map(
        function ( offering ) {


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


            if (
                !groups[
                    groupKey
                ]
            ) {


                groups[
            return {
                    groupKey
                ] = [];


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




            groups[
                 menuName:
                 groupKey
                    menu.menu_name ||
            ].push(
                    '商品',
                menu
            );


        }
    );


                category:
                    menu.item_category ||
                    '',


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


    Object.keys(
                /*
        groups
                * 表示価格
    ).forEach(
                */
        function ( groupKey ) {
                price:
                    cleanNumber(
                        offering.price
                    ),
 


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




            /* =============================
                servingQuantity:
            * 2出店以上あるか確認
                    cleanNumber(
            *
                        offering
            * 同じ出店内だけの商品比較は
                            .serving_quantity
            * 「最安」としない
                    ),
            * ============================= */


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


                                return String(
                servingUnit:
                                    menu.placementId
                    offering
                                );
                        .serving_unit ||
                    '',


                            }
                        )
                    )
                ];




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


                return;


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




            /* =============================
                availability:
            * 最安価格
                    formatAvailability(
            *
                        offering
            * 同商品+同単位の
                            .availability
            * 販売価格を比較
                    ),
            * ============================= */


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


                        return (
                verification:
                            menu.priceValue !==
                    formatVerification(
                                null &&
                        offering
                             Number.isFinite(
                             .verification_status
                                menu.priceValue
                    ),
                            )
                        );


                    }
                );


                isLowestPrice:
                    false,


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


                                return String(
                isLowestUnitPrice:
                                    menu.placementId
                    false
                                );


                            }
            };
                        )
                    )
                ];


        }
    );


            if (
                pricePlacementIds.length >= 2
            ) {


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


                                return menu
    placementId:
                                    .priceValue;
        placementId,


                            }
    placement:
                        )
        placement,
                    );
 
    stall:
        stall,


    festival:
        festival,


                priceCandidates.forEach(
    venue:
                    function ( menu ) {
        venue,


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


                            menu.isLowestPrice =
    menus:
                                true;
        menus


                        }
};


                    }
}
                );
);
                       


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


function markBestPrices(
    compareData
) {


            /* =============================
    const allMenus = [];
            * 最安単位価格
            *
            * 同商品+同単位で
            * price / quantity を比較
            * ============================= */


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


                        return (
    /* =================================
                            menu.unitPriceValue !==
    * 比較文字列を正規化
                                null &&
    *
                            Number.isFinite(
    * 例:
                                menu.unitPriceValue
    * "たこ焼き"
                            )
    * " たこ焼き "
                        );
    *
    * を同じものとして扱う
    * ================================= */


                    }
    function normalizeCompareText(
                );
        value
    ) {


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


             const unitPricePlacementIds =
        let text =
                 [
             String(
                    ...new Set(
                 value
                        unitPriceCandidates.map(
            ).trim();
                            function ( menu ) {


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


                            }
            text =
                        )
                text.normalize(
                     )
                     'NFKC'
                 ];
                 );


        }


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


                const lowestUnitPrice =
        /*
                    Math.min.apply(
        * 英字商品名にも対応
                        null,
        */
                        unitPriceCandidates.map(
        text =
                            function ( menu ) {
            text.toLowerCase();


                                return menu
        return text;
                                    .unitPriceValue;


                            }
    }
                        )
                    );




                unitPriceCandidates.forEach(
    /* =================================
                    function ( menu ) {
    * 全メニューを集める
    * ================================= */


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


                            menu.isLowestUnitPrice =
            if (
                                true;
                !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
                        );
 


function renderProductGroupSummary(
                    allMenus.push(
    compareData
                        menu
) {
                    );


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




        text =
            const groupKey =
            text.replace(
                menu.compareMenuName +
                /\s+/g,
                 '||' +
                 ' '
                menu.compareUnit;
            );


        return text;


    }
            if (
                !groups[
                    groupKey
                ]
            ) {


/* =================================
                groups[
* 屋台ページリンクを生成
                    groupKey
* ================================= */
                ] = [];


function appendStallLinks(
            }
    container,
    items
) {


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


            groups[
                groupKey
            ].push(
                menu
            );


     items.forEach(
        }
        function ( item ) {
     );


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


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


            if (
    Object.keys(
                seen[
        groups
                    key
    ).forEach(
                ]
        function ( groupKey ) {
            ) {
                return;
            }


            const menus =
                groups[
                    groupKey
                ];


            seen[
                key
            ] = true;


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


             stalls.push(
             const placementIds =
                 {
                 [
                     name:
                     ...new Set(
                         item.stallName,
                         menus.map(
                            function ( menu ) {


                    page:
                                return String(
                        item.stallPage
                                    menu.placementId
                }
                                );
            );


        }
                            }
    );
                        )
                    )
                ];




    stalls.forEach(
        function (
            stall,
            index
        ) {
            /*
            * 2件目以降の区切り
            */
             if (
             if (
                 index > 0
                 placementIds.length < 2
             ) {
             ) {


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


             }
             }




             /*
             /* =============================
             * ページが存在する場合
             * 最安価格
             * リンクにする
             *
             */
             * 同商品+同単位の
            if (
            * 販売価格を比較
                stall.page
            * ============================= */
            ) {


                const link =
            const priceCandidates =
                    document.createElement(
                menus.filter(
                        'a'
                     function ( menu ) {
                     );


                link.href =
                        return (
                    mw.util.getUrl(
                            menu.priceValue !==
                        stall.page
                                null &&
                    );
                            Number.isFinite(
                                menu.priceValue
                            )
                        );


                link.textContent =
                     }
                     stall.name;
 
                link.className =
                    'stall-product-group-stall-link';
 
 
                container.appendChild(
                    link
                 );
                 );


            } else {


                /*
            /*
                * page_nameが取得できない場合
            * 価格が登録されている
                * 普通の文字として表示
            * 出店が2件以上あるか
                */
            */
                 container.appendChild(
            const pricePlacementIds =
                     document.createTextNode(
                 [
                         stall.name
                     ...new Set(
                    )
                         priceCandidates.map(
                );
                            function ( menu ) {


            }
                                return String(
                                    menu.placementId
                                );


        }
                            }
    );
                        )
 
                    )
}
                ];
 
    /* =================================
    * 商品グループ作成
    * ================================= */
 
    const groups = {};


    compareData.forEach(
        function ( data ) {


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


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


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


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


                    const unit =
                        normalizeText(
                            menu.servingUnit
                        );


                priceCandidates.forEach(
                    function ( menu ) {


                    /*
                        /*
                    * 商品名または単位が無いものは
                        * 円なので通常整数だが
                    * 商品比較サマリーから除外
                        * 小数にも一応対応
                    */
                        */
                    if (
                        if (
                        !menuName ||
                            Math.abs(
                        !unit
                                menu.priceValue -
                    ) {
                                lowestPrice
                         return;
                            ) <
                    }
                            0.000001
                         ) {


                            menu.isLowestPrice =
                                true;


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


                    }
                );


                    if (
            }
                        !groups[
                            key
                        ]
                    ) {


                        groups[
                            key
                        ] = {


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


                            unit:
            const unitPriceCandidates =
                                unit,
                menus.filter(
                    function ( menu ) {


                             items:
                        return (
                                 []
                            menu.unitPriceValue !==
                                null &&
                             Number.isFinite(
                                 menu.unitPriceValue
                            )
                        );


                        };
                    }
                );


                    }


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


                    groups[
                                return String(
    key
                                    menu.placementId
].items.push(
                                );
    {


        placementId:
                            }
            String(
                        )
                 menu.placementId
                    )
            ),
                 ];


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


        /*
             if (
        * 屋台ページ名
                 unitPricePlacementIds.length >= 2
        */
             ) {
        stallPage:
             (
                 data.stall &&
                data.stall.page_name
             )
                ? data.stall.page_name
                : '',


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


    }
                                return menu
);
                                    .unitPriceValue;


                }
                            }
            );
                        )
                    );


        }
    );


                unitPriceCandidates.forEach(
                    function ( menu ) {


     const groupKeys =
                        /*
         Object.keys(
                        * 割り算による
             groups
                        * 浮動小数誤差対策
                        */
                        if (
                            Math.abs(
                                menu.unitPriceValue -
                                lowestUnitPrice
                            ) <
                            0.000001
                        ) {
 
                            menu.isLowestUnitPrice =
                                true;
 
                        }
 
                    }
                );
 
            }
 
        }
    );
 
}
 
/* =====================================
* 商品別比較サマリー
*
* 同じ商品名 + 同じ単位でグループ化
* ===================================== */
 
function renderProductGroupSummary(
    compareData
) {
 
     const comparePage =
         document.getElementById(
             'stall-compare-page'
         );
         );


     if (
     if (
         groupKeys.length === 0
         !comparePage
     ) {
     ) {
         return;
         return;
6,392行目: 6,617行目:


     /* =================================
     /* =================================
     * サマリー全体
     * 文字列正規化
     * ================================= */
     * ================================= */


     const summary =
     function normalizeText(
         document.createElement(
         value
            'section'
    ) {
        );


    summary.className =
        if (
         'stall-product-group-summary';
            value === undefined ||
            value === null
         ) {
            return '';
        }
 
        let text =
            String(
                value
            ).trim();




    const title =
        if (
        document.createElement(
            typeof text.normalize ===
             'h2'
             'function'
         );
         ) {


    title.className =
            text =
        'stall-product-group-summary-title';
                text.normalize(
                    'NFKC'
                );


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




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


        return text;


     /* =================================
     }
    * 各商品グループ
 
    * ================================= */
/* =================================
* 屋台ページリンクを生成
* ================================= */


     groupKeys.forEach(
function appendStallLinks(
        function ( key ) {
    container,
     items
) {


            const group =
    const stalls = [];
                groups[
    const seen = {};
                    key
                ];


            const items =
                group.items;


    items.forEach(
        function ( item ) {


             /*
             /*
             * 同じPlacementを重複カウントしない
             * page_nameがある場合は
            * page_nameで重複判定
            *
            * 無い場合は名前で判定
             */
             */
             const placementIds =
             const key =
                 [
                 item.stallPage
                     ...new Set(
                     ? 'page:' +
                        items.map(
                      item.stallPage
                            function ( item ) {
                    : 'name:' +
                      item.stallName;


                                return item
                                    .placementId;


                            }
            if (
                        )
                seen[
                     )
                     key
                 ];
                ]
            ) {
                 return;
            }




             const card =
             seen[
                 document.createElement(
                 key
                    'div'
            ] = true;
                );


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


            stalls.push(
                {
                    name:
                        item.stallName,


            /* =============================
                    page:
            * 商品名
                        item.stallPage
            * ============================= */
                }
            );


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


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


            heading.textContent =
    stalls.forEach(
                group.menuName +
        function (
                ' / ' +
            stall,
                group.unit;
            index
        ) {


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


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


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


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


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


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


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


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


/* =============================
                link.className =
* 対象店舗リンク
                    'stall-product-group-stall-link';
* ============================= */


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


stallList.className =
                container.appendChild(
    'stall-product-group-stalls';
                    link
                );


            } else {


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


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


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


}


stallList.appendChild(
    /* =================================
    stallListLabel
    * 商品グループ作成
);
    * ================================= */


    const groups = {};


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


 
    compareData.forEach(
card.appendChild(
        function ( data ) {
    stallList
);
 
            /* =============================
            * 1店舗しかない場合
            * ============================= */


             if (
             if (
                 placementIds.length < 2
                 !data.menus ||
                !Array.isArray(
                    data.menus
                )
             ) {
             ) {
                return;
            }


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


                notice.className =
            data.menus.forEach(
                    'stall-product-group-notice';
                function ( menu ) {


                notice.textContent =
                    const menuName =
                    '比較対象が1店舗のみです。';
                        normalizeText(
                            menu.menuName
                        );


                    const unit =
                        normalizeText(
                            menu.servingUnit
                        );


                card.appendChild(
                    notice
                );


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




            /* =============================
                    const key =
            * 最安価格の商品
                        menuName.toLowerCase() +
            * ============================= */
                        '||' +
                        unit.toLowerCase();


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


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


                    }
                        groups[
                );
                            key
                        ] = {


                            menuName:
                                menuName,


            if (
                            unit:
                lowestPriceItems.length > 0
                                unit,
            ) {


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


                        };


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


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


                    groups[
    key
].items.push(
    {


                const label =
        placementId:
                    document.createElement(
            String(
                        'span'
                menu.placementId
                    );
            ),


                 label.className =
        stallName:
                    'stall-product-group-label';
            (
                data.stall &&
                data.stall.stall_name
            )
                 ? data.stall.stall_name
                : '屋台',


                 label.textContent =
        /*
                    '最安価格:';
        * 屋台ページ名
        */
        stallPage:
            (
                 data.stall &&
                data.stall.page_name
            )
                ? data.stall.page_name
                : '',


        menu:
            menu


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


                 value.textContent =
                 }
                    lowestPrice +
            );
                    '円';


        }
    );


                row.appendChild(
                    label
                );


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




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




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


shopRow.className =
    const summary =
    'stall-product-group-shop';
        document.createElement(
            'section'
        );


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


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


shopLabel.className =
    const title =
    'stall-product-group-label';
        document.createElement(
            'h2'
        );


shopLabel.textContent =
    title.className =
    '最安:';
        'stall-product-group-summary-title';


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


shopRow.appendChild(
    shopLabel
);


    summary.appendChild(
        title
    );


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


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


card.appendChild(
    groupKeys.forEach(
    shopRow
        function ( key ) {
);


             }
             const group =
                groups[
                    key
                ];


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


            /* =============================
                                return item
            * 最安単位価格
                                    .placementId;
            * ============================= */


            const lowestUnitItems =
                            }
                items.filter(
                        )
                     function ( item ) {
                     )
                ];


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


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


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


            if (
                lowestUnitItems.length > 0
            ) {


                const unitPrice =
            /* =============================
                    lowestUnitItems[
            * 商品名
                        0
            * ============================= */
                    ].menu.unitPriceValue;


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


                /*
            heading.className =
                * 小数表示調整
                 'stall-product-group-name';
                */
                 const displayUnitPrice =
                    Math.round(
                        unitPrice *
                        100
                    ) /
                    100;


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


                const row =
                    document.createElement(
                        'div'
                    );
                row.className =
                    'stall-product-group-best-unit';


            card.appendChild(
                heading
            );


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


                label.className =
            /* =============================
                    'stall-product-group-label';
            * 比較店舗数
            * ============================= */


                 label.textContent =
            const count =
                     '最安単位価格:';
                 document.createElement(
                     'div'
                );


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


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


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


            card.appendChild(
                count
            );


                row.appendChild(
/* =============================
                    label
* 対象店舗リンク
                );
* ============================= */


                row.appendChild(
const stallList =
                    value
    document.createElement(
                );
        'div'
    );


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


                card.appendChild(
                    row
                );


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


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


    /*
stallListLabel.textContent =
    * 比較表の一番上へ追加
     '対象店舗:';
    */
    comparePage.insertBefore(
        summary,
        comparePage.firstChild
     );


}


/* =================================
stallList.appendChild(
* 最安値を自動判定
     stallListLabel
* ================================= */
 
markBestPrices(
    compareData
);
 
 
renderComparison(
     compareData
);
);




/*
/*
  * 詳細比較表を描画した後に
  * 屋台名をリンクとして追加
* 商品別サマリーを追加
  */
  */
renderProductGroupSummary(
appendStallLinks(
     compareData
     stallList,
    items
);
);


        }
    ).catch(
        function ( error ) {


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


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


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


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


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


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


} );


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


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


    if (!select) {
        return;
    }


    Array.from(select.options).forEach(function (option) {
             /* =============================
        if (statusLabels[option.value]) {
            * 最安価格の商品
             option.textContent = statusLabels[option.value];
            * ============================= */
        }
    });
});


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


    const statusSelect = document.querySelector(
                        return (
        'select[name="FestivalStallPlacement[status]"]'
                            item.menu
    );
                                .isLowestPrice ===
                            true
                        );


    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(
            if (
        'select[name="FestivalStallPlacement[verification_status]"]'
                lowestPriceItems.length > 0
    );
            ) {


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


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


    const validateYear = function () {
                const row =
        const value = yearInput.value.trim();
                    document.createElement(
                        'div'
                    );


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


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


    validateYear();
                const label =
}
                    document.createElement(
   
                        'span'
    const positionLabels = {
                    );
    exact: '位置確認済み',
    approximate: 'おおよその位置',
    unknown: '位置未確認',
    test: 'テスト位置'
};


const positionSelect = document.querySelector(
                label.className =
    'select[name="FestivalStallPlacement[position_status]"]'
                    'stall-product-group-label';
);


if (positionSelect) {
                label.textContent =
    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 =
        const value = accuracyInput.value.trim();
                    document.createElement(
                        'strong'
                    );


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


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


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


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


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


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


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


    const validateTime = function () {
                /*
         const value = input.value.trim();
* 最安店舗リンク
*/
const shopRow =
    document.createElement(
         'div'
    );


        input.setCustomValidity('');
shopRow.className =
    'stall-product-group-shop';


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


     input.addEventListener('input', validateTime);
const shopLabel =
    input.addEventListener('change', validateTime);
     document.createElement(
     input.addEventListener('invalid', validateTime);
        'span'
    );
 
shopLabel.className =
     'stall-product-group-label';


     validateTime();
shopLabel.textContent =
}
     '最安:';


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


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


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


     input.inputMode = 'decimal';
/*
* 最安店舗をリンク表示
*/
appendStallLinks(
     shopRow,
    lowestPriceItems
);


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


        input.setCustomValidity('');
card.appendChild(
    shopRow
);


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


            return;
        }


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


        /*
            const lowestUnitItems =
        * 範囲チェック
                items.filter(
        */
                    function ( item ) {
        const number = Number(value);


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


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


    input.addEventListener(
        'change',
        validateCoordinate
    );


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


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


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


const validateLatitude =
                /*
    setupCoordinateValidation(
                * 小数表示調整
        latitudeInput,
                */
        '緯度',
                const displayUnitPrice =
        -90,
                    Math.round(
        90
                        unitPrice *
    );
                        100
                    ) /
                    100;


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


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


            if (validateLongitude) {
                 row.className =
                 validateLongitude();
                    'stall-product-group-best-unit';
            }
        }
    );
}
   
    const sourceUrlInput = document.querySelector(
    'input[name="FestivalStallPlacement[source_url]"]'
);


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


    const validateSourceUrl = function () {
                const label =
        const value = sourceUrlInput.value.trim();
                    document.createElement(
                        'span'
                    );


        sourceUrlInput.setCustomValidity('');
                label.className =
                    'stall-product-group-label';


        if (value === '') {
                label.textContent =
            return;
                    '最安単位価格:';
        }


        try {
            const url = new URL(value);


            if (url.protocol !== 'http:' && url.protocol !== 'https:') {
                const value =
                 sourceUrlInput.setCustomValidity(
                    document.createElement(
                     '情報元URLは http:// または https:// で始まるURLを入力してください。'
                        'strong'
                    );
 
                value.textContent =
                    displayUnitPrice +
                    '円/' +
                    group.unit;
 
 
                 row.appendChild(
                     label
                 );
                 );
            }
        } catch (e) {
            sourceUrlInput.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
        }
    };


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


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


if (sortOrderInput) {
                card.appendChild(
    sortOrderInput.inputMode = 'numeric';
                    row
                );


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


        sortOrderInput.setCustomValidity('');


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


     sortOrderInput.addEventListener('input', validateSortOrder);
     /*
    sortOrderInput.addEventListener('change', validateSortOrder);
    * 比較表の一番上へ追加
     sortOrderInput.addEventListener('invalid', validateSortOrder);
    */
    comparePage.insertBefore(
        summary,
        comparePage.firstChild
     );


    validateSortOrder();
}
}
   
});


/**
/* =================================
  * FestivalStallPlacement - 最終確認日の未来日チェック
  * 最安値を自動判定
  */
  * ================================= */
(function () {
 
'use strict';
markBestPrices(
    compareData
);


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


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


dateInput.dataset.lastConfirmedValidation = '1';


function getVisibleInput() {
/*
const widget = dateInput.closest('.oo-ui-widget');
* 詳細比較表を描画した後に
* 商品別サマリーを追加
*/
renderProductGroupSummary(
    compareData
);


if (!widget) {
        }
return null;
    ).catch(
}
        function ( error ) {


return widget.querySelector('input[type="text"]');
            console.error(
}
                'Placement比較データ取得エラー:',
                error
            );


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


if (!widget) {
            renderMessage(
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() {
$(function () {
const visibleInput = getVisibleInput();
const statusLabels = {
const error = getErrorElement();
    active: '出店中・出店予定',
    cancelled: '出店中止',
    unknown: '未確認'
};


if (!visibleInput || !error) {
    const statusSelect = document.querySelector(
return;
        'select[name="FestivalStallPlacement[status]"]'
}
    );


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


if (dateInput.validity.rangeOverflow) {
    const verificationLabels = {
const maxDate = dateInput.max.replace(/-/g, '/');
        verified: '確認済み',
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };


message =
    const verificationSelect = document.querySelector(
'未来の日付は入力できません。' +
        'select[name="FestivalStallPlacement[verification_status]"]'
maxDate +
    );
'以前の日付を入力してください。';
} else {
message =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}


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


visibleInput.setAttribute('aria-invalid', 'true');
if (yearInput) {
}
    yearInput.inputMode = 'numeric';
    yearInput.maxLength = 4;


function clearError() {
    const validateYear = function () {
const visibleInput = getVisibleInput();
        const value = yearInput.value.trim();
const error = getErrorElement();


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


if (visibleInput) {
    yearInput.addEventListener('input', validateYear);
visibleInput.removeAttribute('aria-invalid');
    yearInput.addEventListener('change', validateYear);
}
    yearInput.addEventListener('invalid', validateYear);
}


/*
    validateYear();
* 非表示の date input に対する
}
* ブラウザ標準エラー表示を止める。
   
*/
const positionLabels = {
dateInput.addEventListener('invalid', function (event) {
    exact: '位置確認済み',
event.preventDefault();
    approximate: 'おおよその位置',
    unknown: '位置未確認'
};


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


const visibleInput = getVisibleInput();
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 (visibleInput) {
if (accuracyInput) {
window.setTimeout(function () {
    accuracyInput.inputMode = 'numeric';
visibleInput.focus();
}, 0);
}
});


/*
    const validateAccuracy = function () {
* ユーザーが日付を修正したら
        const value = accuracyInput.value.trim();
* 有効になった時点でエラーを消す。
*/
const visibleInput = getVisibleInput();


if (visibleInput) {
        if (value !== '' && !/^\d+$/.test(value)) {
['input', 'change'].forEach(function (eventName) {
            accuracyInput.setCustomValidity(
visibleInput.addEventListener(eventName, function () {
                '位置精度は0以上の整数で入力してください(例:10)'
window.setTimeout(function () {
            );
if (dateInput.validity.valid) {
        } else {
clearError();
            accuracyInput.setCustomValidity('');
} else if (dateInput.validity.rangeOverflow) {
        }
showError();
    };
}
}, 0);
});
});
}
});
}


if (document.readyState === 'loading') {
    accuracyInput.addEventListener('input', validateAccuracy);
document.addEventListener(
    accuracyInput.addEventListener('change', validateAccuracy);
'DOMContentLoaded',
    accuracyInput.addEventListener('invalid', validateAccuracy);
setupLastConfirmedValidation
);
} else {
setupLastConfirmedValidation();
}


mw.hook('wikipage.content').add(function () {
    validateAccuracy();
setupLastConfirmedValidation();
}
});
   
})();
    const openingTimeInput = document.querySelector(
    'input[name="FestivalStallPlacement[opening_time]"]'
);


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


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


function setupDuplicateWarning() {
function setupTimeValidation(input, label) {
const form = document.getElementById('pfForm');
    if (!input) {
        return;
    }


if (!form) {
    input.placeholder = '例:10:00';
return;
}


if (form.dataset.duplicateWarningV2 === '1') {
    const validateTime = function () {
return;
        const value = input.value.trim();
}
 
        input.setCustomValidity('');


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


if (!table) {
    input.addEventListener('input', validateTime);
return;
    input.addEventListener('change', validateTime);
}
    input.addEventListener('invalid', validateTime);


form.dataset.duplicateWarningV2 = '1';
    validateTime();
}


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


warning.className = 'stall-duplicate-warning';
const longitudeInput = document.querySelector(
warning.setAttribute('role', 'status');
    'input[name="FestivalStallPlacement[longitude]"]'
warning.hidden = true;
);


/*
function setupCoordinateValidation(input, label, min, max) {
* 表の中ではなく、表の直前に置く。
    if (!input) {
* 警告表示でフォームの列幅を崩さない。
        return null;
*/
    }
table.insertAdjacentElement('beforebegin', warning);


let timer = null;
    input.inputMode = 'decimal';
let requestId = 0;


function escapeCargo(value) {
    const validateCoordinate = function () {
return String(value).replace(/'/g, "''");
        const value = input.value.trim();
}


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


function cargoQuery(tableName, fields, where, limit) {
        /*
return api.get({
        * exact または approximate の場合は
action: 'cargoquery',
        * 緯度・経度を必須にする。
tables: tableName,
        */
fields: fields,
        if (value === '') {
where: where,
            if (
limit: limit || 50,
                positionSelect &&
format: 'json'
                (
}).then(function (data) {
                    positionSelect.value === 'exact' ||
if (
                    positionSelect.value === 'approximate'
!data ||
                )
!Array.isArray(data.cargoquery)
            ) {
) {
                input.setCustomValidity(
return [];
                    label +
}
                    'は「位置確認済み」または「おおよその位置」を選択した場合は必須です。'
                );
 
                return;
            }


return data.cargoquery.map(function (item) {
            const otherInput =
return item.title || item;
                input === latitudeInput
});
                    ? longitudeInput
});
                    : latitudeInput;
}


function resolveId(
            if (
tableName,
                otherInput &&
idField,
                otherInput.value.trim() !== ''
nameField,
            ) {
value
                input.setCustomValidity(
) {
                    '緯度と経度は両方入力するか、両方空欄にしてください。'
if (!value) {
                );
return Promise.resolve(null);
            }
}


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


return cargoQuery(
        /*
tableName,
        * 数値形式チェック
idField + '=resolved_id',
        */
nameField +
        if (!/^-?\d+(\.\d+)?$/.test(value)) {
"='" +
            input.setCustomValidity(
escapeCargo(value) +
                label + 'は数値で入力してください。'
"'",
            );
2
            return;
).then(function (rows) {
        }
if (rows.length !== 1) {
console.warn(
'IDを一意に取得できません:',
tableName,
value,
rows
);


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


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


function clearWarning() {
    input.addEventListener(
warning.hidden = true;
        'input',
warning.replaceChildren();
        validateCoordinate
}
    );


function showFailure() {
    input.addEventListener(
warning.replaceChildren();
        'change',
        validateCoordinate
    );


const text = document.createElement('div');
    input.addEventListener(
        'invalid',
        validateCoordinate
    );


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


warning.appendChild(text);
    /*
warning.hidden = false;
    * position_status変更時に
}
    * 再チェックできるよう関数を返す。
    */
    return validateCoordinate;
}


function showCandidates(rows) {
const validateLatitude =
warning.replaceChildren();
    setupCoordinateValidation(
        latitudeInput,
        '緯度',
        20,
        46
    );


const positionLabels = {
const validateLongitude =
exact: '位置確認済み',
    setupCoordinateValidation(
approximate: 'おおよその位置',
        longitudeInput,
unknown: '位置未確認',
        '経度',
test: 'テスト位置'
        122,
};
        154
    );


const verificationLabels = {
/*
verified: '確認済み',
* 一方の座標を変更した場合、
partially_verified: '一部確認済み',
* 反対側のペア整合性も再検証する。
unverified: '未確認',
*/
outdated: '情報が古い可能性あり'
if (
};
    latitudeInput &&
    validateLongitude
const statusLabels = {
) {
active: '出店中・出店予定',
    latitudeInput.addEventListener(
cancelled: '出店中止',
        'input',
unknown: '未確認',
        validateLongitude
test: 'テストデータ'
    );
};


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


return String(value);
if (
}
    longitudeInput &&
    validateLatitude
) {
    longitudeInput.addEventListener(
        'input',
        validateLatitude
    );


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


const labelElement =
/*
document.createElement('span');
* 位置情報の状態を変更した場合、
 
* 緯度・経度を再検証する。
labelElement.className =
*/
'stall-duplicate-candidate-label';
if (positionSelect) {
    positionSelect.addEventListener(
        'change',
        function () {
            if (validateLatitude) {
                validateLatitude();
            }


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


const valueElement =
if (sourceUrlInput) {
document.createElement('span');
    sourceUrlInput.inputMode = 'url';


valueElement.className =
    const validateSourceUrl = function () {
'stall-duplicate-candidate-value';
        const value = sourceUrlInput.value.trim();


valueElement.textContent = value;
        sourceUrlInput.setCustomValidity('');


row.appendChild(labelElement);
        if (value === '') {
row.appendChild(valueElement);
            return;
        }


container.appendChild(row);
        try {
}
            const url = new URL(value);


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


title.className =
    sourceUrlInput.addEventListener('input', validateSourceUrl);
'stall-duplicate-warning-title';
    sourceUrlInput.addEventListener('change', validateSourceUrl);
    sourceUrlInput.addEventListener('invalid', validateSourceUrl);


title.textContent =
    validateSourceUrl();
'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
}
rows.length +
   
'件あります。';
    const sortOrderInput = document.querySelector(
 
    'input[name="FestivalStallPlacement[sort_order]"]'
warning.appendChild(title);
);
 
if (sortOrderInput) {
    sortOrderInput.inputMode = 'numeric';


const description =
    const validateSortOrder = function () {
document.createElement('p');
        const value = sortOrderInput.value.trim();


description.className =
        sortOrderInput.setCustomValidity('');
'stall-duplicate-warning-description';


description.textContent =
        if (value !== '' && !/^\d+$/.test(value)) {
'出店場所が異なる場合は新規登録して構いません。' +
            sortOrderInput.setCustomValidity(
'下の既存データと同じ場所ではないか確認してください。';
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    };


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


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


list.className =
/**
'stall-duplicate-candidate-list';
* FestivalStallPlacement - 最終確認日の未来日チェック
*/
(function () {
'use strict';


/*
function setupLastConfirmedValidation() {
* placement_id順に並べる
const dateInputs = document.querySelectorAll(
*/
'input[name="FestivalStallPlacement[last_confirmed]"]'
rows.sort(function (a, b) {
return (
Number(a.placement_id) -
Number(b.placement_id)
);
);
});


rows.forEach(function (row) {
dateInputs.forEach(function (dateInput) {
const card =
if (dateInput.dataset.lastConfirmedValidation === '1') {
document.createElement('div');
return;
}
 
dateInput.dataset.lastConfirmedValidation = '1';


card.className =
function getVisibleInput() {
'stall-duplicate-candidate';
const widget = dateInput.closest('.oo-ui-widget');


/*
if (!widget) {
* カード見出し
return null;
*/
}
const header =
document.createElement('div');


header.className =
return widget.querySelector('input[type="text"]');
'stall-duplicate-candidate-header';
}


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


heading.textContent =
if (!widget) {
'既存の出店情報';
return null;
}


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


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


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


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


/*
function showError() {
* 緯度・経度
const visibleInput = getVisibleInput();
*/
const error = getErrorElement();
let coordinates =
 
'位置情報なし';
if (!visibleInput || !error) {
return;
}


if (
let message;
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(
if (dateInput.validity.rangeOverflow) {
card,
const maxDate = dateInput.max.replace(/-/g, '/');
'緯度・経度',
coordinates
);


/*
message =
* 位置精度
'未来の日付は入力できません。' +
*/
maxDate +
let accuracy = '未確認';
'以前の日付を入力してください。';
} else {
message =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}


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


addDetail(
visibleInput.setAttribute('aria-invalid', 'true');
card,
}
'位置精度',
accuracy
);


/*
function clearError() {
* 出店状態
const visibleInput = getVisibleInput();
*/
const error = getErrorElement();
addDetail(
 
card,
if (error) {
'出店状態',
error.hidden = true;
statusLabels[
error.textContent = '';
row.status
}
] ||
displayValue(
row.status,
'未確認'
)
);


/*
if (visibleInput) {
* 最終確認日
visibleInput.removeAttribute('aria-invalid');
*/
}
let lastConfirmed = '未確認';
}


if (
/*
row.last_confirmed !== undefined &&
* 非表示の date input に対する
row.last_confirmed !== null &&
* ブラウザ標準エラー表示を止める。
String(row.last_confirmed).trim() !== ''
*/
) {
dateInput.addEventListener('invalid', function (event) {
lastConfirmed =
event.preventDefault();
String(row.last_confirmed)
.replace(/-/g, '/');
}


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


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


/*
if (visibleInput) {
* 既存ページへのリンク
window.setTimeout(function () {
*/
visibleInput.focus();
const actions =
}, 0);
document.createElement('div');
}
});


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


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


link.href =
        if (
mw.util.getUrl(row.page_name);
            !currentWidget ||
            !currentWidget.contains(event.target)
        ) {
            return;
        }


link.target = '_blank';
        window.setTimeout(function () {
link.rel = 'noopener';
            if (dateInput.validity.valid) {
                clearError();
            } else if (
                dateInput.validity.rangeOverflow
            ) {
                showError();
            }
        }, 0);
    }


link.textContent =
    form.addEventListener(
'既存データを確認';
        'input',
        handleDateChange
    );


actions.appendChild(link);
    form.addEventListener(
card.appendChild(actions);
        'change',
        handleDateChange
    );


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


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


const footer =
mw.hook('wikipage.content').add(function () {
document.createElement('div');
setupLastConfirmedValidation();
});
})();


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


footer.textContent =
const api = new mw.Api();
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';


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


warning.hidden = false;
if (!form) {
}
return;
}


function checkDuplicates() {
if (form.dataset.duplicateWarningV2 === '1') {
const currentRequest = ++requestId;
return;
}


/*
const table = form.querySelector('.formtable');
* 毎回現在のinput/selectを取得する。
* Page Formsが要素を作り直しても対応できる。
*/
const stall = getField('stall_id');
const festival = getField('festival_id');
const venue = getField('venue_id');
const year = getField('year');


if (
if (!table) {
!stall ||
return;
!festival ||
}
!venue ||
!year
) {
clearWarning();
return;
}


const stallValue = stall.value.trim();
form.dataset.duplicateWarningV2 = '1';
const festivalValue = festival.value.trim();
const venueValue = venue.value.trim();
const yearValue = year.value.trim();


if (
const warning = document.createElement('div');
!stallValue ||
!festivalValue ||
!venueValue ||
!/^\d{4}$/.test(yearValue)
) {
clearWarning();
return;
}


/*
warning.className = 'stall-duplicate-warning';
* async / await は使わず、
warning.setAttribute('role', 'status');
* Promise の then() で処理する。
warning.hidden = true;
*/
 
Promise.all([
/*
resolveId(
* 表の中ではなく、表の直前に置く。
'Stalls',
* 警告表示でフォームの列幅を崩さない。
'stall_id',
*/
'name',
table.insertAdjacentElement('beforebegin', warning);
stallValue
 
),
let timer = null;
resolveId(
let requestId = 0;
'Festivals',
 
'festival_id',
function escapeCargo(value) {
'name',
return String(value).replace(/'/g, "''");
festivalValue
),
resolveId(
'Venues',
'venue_id',
'name',
venueValue
)
])
.then(function (ids) {
if (currentRequest !== requestId) {
return null;
}
}


if (!ids[0] || !ids[1] || !ids[2]) {
function getField(name) {
clearWarning();
return form.querySelector(
return null;
'[name="FestivalStallPlacement[' +
name +
']"]'
);
}
}


const where =
function cargoQuery(tableName, fields, where, limit) {
'festival_id=' +
return api.get({
ids[1] +
action: 'cargoquery',
' AND year=' +
tables: tableName,
yearValue +
fields: fields,
' AND venue_id=' +
where: where,
ids[2] +
limit: limit || 50,
' AND stall_id=' +
format: 'json'
ids[0];
}).then(function (data) {
 
if (
return cargoQuery(
!data ||
'FestivalStallPlacements',
!Array.isArray(data.cargoquery)
'placement_id=placement_id,' +
) {
'location_note=location_note,' +
return [];
'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(
return data.cargoquery.map(function (item) {
'FestivalStallPlacement候補:',
return item.title || item;
where,
});
rows
});
);
}


if (rows.length === 0) {
function resolveId(
clearWarning();
tableName,
return;
idField,
nameField,
value
) {
if (!value) {
return Promise.resolve(null);
}
}


/*
if (/^\d+$/.test(value)) {
* 今回は警告のみ。
return Promise.resolve(value);
* 同条件の既存データをすべて表示する。
}
*/
showCandidates(rows);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);


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


function scheduleCheck() {
return null;
window.clearTimeout(timer);
}


timer = window.setTimeout(
return String(rows[0].resolved_id);
checkDuplicates,
});
300
);
}
}


/*
function clearWarning() {
* form自身へイベントを設定する。
warning.hidden = true;
* dropdownが後から置き換わっても拾える。
warning.replaceChildren();
*/
}
form.addEventListener('change', function (event) {
const name = event.target.name || '';


if (
function showFailure() {
name ===
warning.replaceChildren();
'FestivalStallPlacement[stall_id]' ||
name ===
'FestivalStallPlacement[festival_id]' ||
name ===
'FestivalStallPlacement[venue_id]' ||
name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


form.addEventListener('input', function (event) {
const text = document.createElement('div');
if (
event.target.name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});


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


if (document.readyState === 'loading') {
warning.appendChild(text);
document.addEventListener(
warning.hidden = false;
'DOMContentLoaded',
}
setupDuplicateWarning
 
);
function showCandidates(rows, venueSpecified) {
} else {
warning.replaceChildren();
setupDuplicateWarning();
}


mw.hook('pf.formSetupAfter').add(
const positionLabels = {
setupDuplicateWarning
    exact: '位置確認済み',
);
    approximate: 'おおよその位置',
});
    unknown: '位置未確認'
};


/*
const verificationLabels = {
* FestivalStallMenuOffering
verified: '確認済み',
* 入力検証・日本語表示
partially_verified: '一部確認済み',
*/
unverified: '未確認',
(function () {
outdated: '情報が古い可能性あり'
     'use strict';
};
const statusLabels = {
     active: '出店中・出店予定',
    cancelled: '出店中止',
    unknown: '未確認'
};


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


    var availabilityLabels = {
return String(value);
        available: '販売中',
}
        unknown: '未確認'
    };


    var verificationLabels = {
function addDetail(container, label, value) {
        verified: '確認済み',
const row = document.createElement('div');
        partially_verified: '一部確認済み',
row.className =
        unverified: '未確認',
'stall-duplicate-candidate-detail';
        outdated: '情報が古い可能性あり'
    };


    function isOfferingField(element) {
const labelElement =
        return !!(
document.createElement('span');
            element &&
 
            element.name &&
labelElement.className =
            element.name.indexOf(
'stall-duplicate-candidate-label';
                'FestivalStallMenuOffering['
 
            ) === 0
labelElement.textContent = label;
        );
    }


    function isTemplateField(element) {
const valueElement =
        return !!(
document.createElement('span');
            element &&
            element.name &&
            element.name.indexOf('[num]') !== -1
        );
    }


    function fieldNameEndsWith(element, suffix) {
valueElement.className =
        return !!(
'stall-duplicate-candidate-value';
            element &&
            element.name &&
            element.name.slice(-suffix.length) === suffix
        );
    }


    function localizeSelect(select, labels) {
valueElement.textContent = value;
        if (!select) {
            return;
        }


        Array.from(select.options).forEach(
row.appendChild(labelElement);
            function (option) {
row.appendChild(valueElement);
                if (
                    Object.prototype.hasOwnProperty.call(
                        labels,
                        option.value
                    ) &&
                    option.textContent !==
                        labels[option.value]
                ) {
                    option.textContent =
                        labels[option.value];
                }
            }
        );
    }


    function validatePrice(input) {
container.appendChild(row);
        var value = input.value.trim();
}


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


        if (
title.className =
            value !== '' &&
'stall-duplicate-warning-title';
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '価格は0以上の整数で入力してください(例:600)'
            );
        }
    }


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


        input.setCustomValidity('');
warning.appendChild(title);


        if (value === '') {
const description =
            return;
document.createElement('p');
        }


        if (
description.className =
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
'stall-duplicate-warning-description';
        ) {
            input.setCustomValidity(
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
            );
        }
    }


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


        input.setCustomValidity('');
warning.appendChild(description);


        if (
const list = document.createElement('div');
            value !== '' &&
            !/^\d+$/.test(value)
        ) {
            input.setCustomValidity(
                '限定数量は0以上の整数で入力してください(例:100)'
            );
        }
    }


    function validateSortOrder(input) {
list.className =
        var value = input.value.trim();
'stall-duplicate-candidate-list';


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


        if (
rows.forEach(function (row) {
            value !== '' &&
const card =
            !/^\d+$/.test(value)
document.createElement('div');
        ) {
            input.setCustomValidity(
                '表示順は0以上の整数で入力してください(例:1)'
            );
        }
    }


    function validateSourceUrl(input) {
card.className =
        var value = input.value.trim();
'stall-duplicate-candidate';


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


        if (value === '') {
header.className =
            return;
'stall-duplicate-candidate-header';
        }


        try {
const heading =
            var url = new URL(value);
document.createElement('strong');


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


    function validateLastConfirmed(input) {
header.appendChild(heading);
        var value = input.value;
        var max = input.max;


        input.setCustomValidity('');
card.appendChild(header);


        if (
/*
            value !== '' &&
* 出店場所
            max !== '' &&
*/
            value > max
addDetail(
        ) {
card,
            input.setCustomValidity(
'出店場所',
                '未来の日付は入力できません。' +
displayValue(
                max.replace(/-/g, '/') +
row.location_note,
                '以前の日付を入力してください。'
'場所メモなし'
            );
)
        }
);
    }


    function getVisibleDateInput(dateInput) {
/*
        var widget =
* 位置状態
            dateInput.closest('.oo-ui-widget');
*/
addDetail(
card,
'位置状態',
positionLabels[
row.position_status
] ||
displayValue(
row.position_status,
'位置未確認'
)
);


        if (!widget) {
/*
            return null;
* 緯度・経度
        }
*/
let coordinates =
'位置情報なし';


        return widget.querySelector(
if (
            'input[type="text"]'
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);
}


    function getDateErrorElement(dateInput) {
addDetail(
        var widget =
card,
            dateInput.closest('.oo-ui-widget');
'緯度・経度',
coordinates
);


        if (!widget) {
/*
            return null;
* 位置精度
        }
*/
let accuracy = '未確認';


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


        if (
addDetail(
            next &&
card,
            next.classList.contains(
'位置精度',
                'stall-offering-last-confirmed-error'
accuracy
            )
);
        ) {
            return next;
        }


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


        /*
/*
        * 既存の最終確認日エラー用CSSも利用する。
* 最終確認日
        */
*/
        error.className =
let lastConfirmed = '未確認';
            'stall-last-confirmed-error ' +
            'stall-offering-last-confirmed-error';


        error.setAttribute(
if (
            'role',
row.last_confirmed !== undefined &&
            'alert'
row.last_confirmed !== null &&
        );
String(row.last_confirmed).trim() !== ''
) {
lastConfirmed =
String(row.last_confirmed)
.replace(/-/g, '/');
}
 
addDetail(
card,
'最終確認日',
lastConfirmed
);


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


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


        return error;
actions.className =
    }
'stall-duplicate-candidate-actions';


    function showDateError(dateInput) {
const link =
        var visibleInput =
document.createElement('a');
            getVisibleDateInput(dateInput);


        var error =
link.href =
            getDateErrorElement(dateInput);
mw.util.getUrl(row.page_name);


        if (!error) {
link.target = '_blank';
            return;
link.rel = 'noopener';
        }


        var maxDate =
link.textContent =
            dateInput.max
'既存データを確認';
                ? dateInput.max.replace(/-/g, '/')
                : '';


        if (
actions.appendChild(link);
            dateInput.validity.rangeOverflow ||
card.appendChild(actions);
            (
                dateInput.value &&
                dateInput.max &&
                dateInput.value > dateInput.max
            )
        ) {
            error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
            error.textContent =
                dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }


        error.hidden = false;
list.appendChild(card);
});


        if (visibleInput) {
warning.appendChild(list);
            visibleInput.setAttribute(
                'aria-invalid',
                'true'
            );
        }
    }


    function clearDateError(dateInput) {
const footer =
        var visibleInput =
document.createElement('div');
            getVisibleDateInput(dateInput);


        var widget =
footer.className =
            dateInput.closest('.oo-ui-widget');
'stall-duplicate-warning-footer';


        var error = null;
footer.textContent =
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';


        if (
warning.appendChild(footer);
            widget &&
            widget.nextElementSibling &&
            widget.nextElementSibling.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            error =
                widget.nextElementSibling;
        }


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


        if (visibleInput) {
function checkDuplicates() {
            visibleInput.removeAttribute(
const currentRequest = ++requestId;
                'aria-invalid'
            );
        }
    }


    function getLimitedQuantityInput(
/*
        checkbox,
* 毎回現在のinput/selectを取得する。
        form
* Page Formsが要素を作り直しても対応できる。
    ) {
*/
        if (!checkbox || !checkbox.name) {
const stall = getField('stall_id');
            return null;
const festival = getField('festival_id');
        }
const venue = getField('venue_id');
const year = getField('year');


        var quantityName =
if (
            checkbox.name.replace(
!stall ||
                /\[limited\]\[value\]$/,
!festival ||
                '[limited_quantity]'
!venue ||
            );
!year
) {
clearWarning();
return;
}


        return Array.from(
const stallValue = stall.value.trim();
            form.querySelectorAll(
const festivalValue = festival.value.trim();
                'input[name^="FestivalStallMenuOffering["]'
const venueValue = venue.value.trim();
            )
const yearValue = year.value.trim();
        ).find(
            function (input) {
                return input.name === quantityName;
            }
        ) || null;
    }


    function updateLimitedState(
if (
        checkbox,
!stallValue ||
        form,
!festivalValue ||
        clearWhenOff
!/^\d{4}$/.test(yearValue)
    ) {
) {
        var quantityInput =
clearWarning();
            getLimitedQuantityInput(
return;
                checkbox,
}
                form
            );


        if (!quantityInput) {
/*
            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 (checkbox.checked) {
if (
            quantityInput.disabled = false;
!ids[0] ||
            quantityInput.removeAttribute(
!ids[1] ||
                'aria-disabled'
(
            );
venueValue !== '' &&
        } else {
!ids[2]
            if (clearWhenOff) {
)
                quantityInput.value = '';
) {
            }
clearWarning();
return null;
}


            quantityInput.setCustomValidity('');
let where =
            quantityInput.disabled = true;
'festival_id=' +
            quantityInput.setAttribute(
ids[1] +
                'aria-disabled',
' AND year=' +
                'true'
yearValue +
            );
' AND stall_id=' +
        }
ids[0];
    }


    function validateField(element) {
if (ids[2]) {
        if (
where +=
            !isOfferingField(element) ||
' AND venue_id=' +
            isTemplateField(element)
ids[2];
        ) {
}
            return;
        }


        if (
return cargoQuery(
            fieldNameEndsWith(
'FestivalStallPlacements',
                element,
'placement_id=placement_id,' +
                '[price]'
'location_note=location_note,' +
            )
'latitude=latitude,' +
        ) {
'longitude=longitude,' +
            validatePrice(element);
'position_status=position_status,' +
            return;
'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 (
if (rows.length === 0) {
            fieldNameEndsWith(
clearWarning();
                element,
return;
                '[serving_quantity]'
}
            )
        ) {
            validateServingQuantity(element);
            return;
        }


        if (
/*
            fieldNameEndsWith(
* 今回は警告のみ。
                element,
* 同条件の既存データをすべて表示する。
                '[limited_quantity]'
*/
            )
const rawPageName =
        ) {
String(
            validateLimitedQuantity(element);
mw.config.get('wgPageName') ||
            return;
''
        }
);


        if (
const formEditMarker =
            fieldNameEndsWith(
'/FestivalStallPlacement/';
                element,
 
                '[sort_order]'
const markerIndex =
            )
rawPageName.indexOf(
        ) {
formEditMarker
            validateSortOrder(element);
);
            return;
        }


        if (
const currentPlacementPage =
            fieldNameEndsWith(
markerIndex >= 0
                element,
? rawPageName
                '[source_url]'
.slice(
            )
markerIndex +
        ) {
formEditMarker.length
            validateSourceUrl(element);
)
            return;
.replace(/_/g, ' ')
        }
.trim()
: '';


        if (
const filteredRows =
            fieldNameEndsWith(
currentPlacementPage
                element,
? rows.filter(function (row) {
                '[last_confirmed]'
return (
            )
String(
        ) {
row.page_name ||
            validateLastConfirmed(element);
''
)
.replace(/_/g, ' ')
.trim() !==
currentPlacementPage
);
})
: rows;


            if (element.validity.valid) {
if (filteredRows.length === 0) {
                clearDateError(element);
clearWarning();
            }
return;
}


            return;
showCandidates(
        }
filteredRows,
    }
venueValue !== ''
);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);


    function initializeFields(form) {
showFailure();
        /*
});
        * 販売状態を日本語化。
}
        * [num]も変更しておくことで、
        * 後から追加されるmultipleにも反映される。
        */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[availability]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    availabilityLabels
                );
            }
        );


        /*
function scheduleCheck() {
        * 確認状態を日本語化。
window.clearTimeout(timer);
        */
        form.querySelectorAll(
            'select[name^="FestivalStallMenuOffering["]' +
            '[name$="[verification_status]"]'
        ).forEach(
            function (select) {
                localizeSelect(
                    select,
                    verificationLabels
                );
            }
        );


        /*
timer = window.setTimeout(
        * 数値入力向けキーボード。
checkDuplicates,
        */
300
        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["]' +
* form自身へイベントを設定する。
            '[name$="[serving_quantity]"]'
* dropdownが後から置き換わっても拾える。
        ).forEach(
*/
            function (input) {
form.addEventListener('change', function (event) {
                input.inputMode = 'decimal';
const name = event.target.name || '';
            }
        );


        form.querySelectorAll(
if (
            'input[name^="FestivalStallMenuOffering["]' +
name ===
            '[name$="[source_url]"]'
'FestivalStallPlacement[stall_id]' ||
        ).forEach(
name ===
            function (input) {
'FestivalStallPlacement[festival_id]' ||
                input.inputMode = 'url';
name ===
            }
'FestivalStallPlacement[venue_id]' ||
        );
name ===
 
'FestivalStallPlacement[year]'
        /*
) {
        * 限定数量欄のON/OFF。
scheduleCheck();
        */
}
        form.querySelectorAll(
});
            'input[type="checkbox"]' +
            '[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited][value]"]'
        ).forEach(
            function (checkbox) {
                updateLimitedState(
                    checkbox,
                    form,
                    false
                );
            }
        );


        /*
form.addEventListener('input', function (event) {
        * 現在値を一度検証。
if (
        * [num]は除外。
event.target.name ===
        */
'FestivalStallPlacement[year]'
        form.querySelectorAll(
) {
            '[name^="FestivalStallMenuOffering["]'
scheduleCheck();
        ).forEach(
}
            function (element) {
});
                validateField(element);
            }
        );
    }


    function setupOfferingValidation() {
scheduleCheck();
        var form =
}
            document.getElementById(
                FORM_ID
            );


        if (!form) {
if (document.readyState === 'loading') {
            return;
document.addEventListener(
        }
'DOMContentLoaded',
setupDuplicateWarning
);
} else {
setupDuplicateWarning();
}


        /*
mw.hook('pf.formSetupAfter').add(
        * wikipage.content 等で再度呼ばれても
setupDuplicateWarning
        * イベントを二重登録しない。
);
        */
});
        if (
            form.dataset
                .offeringValidationInitialized ===
            '1'
        ) {
            initializeFields(form);
            return;
        }


        form.dataset
/*
            .offeringValidationInitialized =
* FestivalStallMenuOffering
            '1';
* 入力検証・日本語表示
*/
(function () {
    'use strict';


        /*
    var FORM_ID = 'pfForm';
        * multipleで後から追加された項目にも効くよう
        * form側でイベント委譲。
        */
        form.addEventListener(
            'input',
            function (event) {
                validateField(
                    event.target
                );
            }
        );


         form.addEventListener(
    var availabilityLabels = {
            'change',
         available: '販売中',
            function (event) {
        unknown: '未確認'
                var target =
    };
                    event.target;


                if (!isOfferingField(target)) {
    var verificationLabels = {
                    return;
        verified: '確認済み',
                }
        partially_verified: '一部確認済み',
        unverified: '未確認',
        outdated: '情報が古い可能性あり'
    };


                if (
    function isOfferingField(element) {
                    target.type === 'checkbox' &&
        return !!(
                    fieldNameEndsWith(
            element &&
                        target,
            element.name &&
                        '[limited][value]'
            element.name.indexOf(
                    )
                'FestivalStallMenuOffering['
                ) {
            ) === 0
                    updateLimitedState(
        );
                        target,
    }
                        form,
 
                        true
    function isTemplateField(element) {
                    );
        return !!(
                }
            element &&
            element.name &&
            element.name.indexOf('[num]') !== -1
        );
    }


                validateField(target);
    function fieldNameEndsWith(element, suffix) {
             }
        return !!(
            element &&
            element.name &&
             element.name.slice(-suffix.length) === suffix
         );
         );
    }


        /*
    function localizeSelect(select, labels) {
        * invalidイベントは通常bubbleしないため
        if (!select) {
        * capture=trueで取得する。
            return;
        */
        }
        form.addEventListener(
            'invalid',
            function (event) {
                var target =
                    event.target;


        Array.from(select.options).forEach(
            function (option) {
                 if (
                 if (
                     !isOfferingField(target) ||
                     Object.prototype.hasOwnProperty.call(
                     isTemplateField(target)
                        labels,
                        option.value
                    ) &&
                     option.textContent !==
                        labels[option.value]
                 ) {
                 ) {
                     return;
                     option.textContent =
                        labels[option.value];
                 }
                 }
            }
        );
    }


                validateField(target);
    function validatePrice(input) {
        var value = input.value.trim();
 
        input.setCustomValidity('');


                if (
        if (
                    fieldNameEndsWith(
            value !== '' &&
                        target,
            !/^\d+$/.test(value)
                        '[last_confirmed]'
        ) {
                    )
            input.setCustomValidity(
                ) {
                '価格は0以上の整数で入力してください(例:600)'
                    event.preventDefault();
            );
        }
    }
 
    function validateServingQuantity(input) {
        var value = input.value.trim();


                    showDateError(target);
        input.setCustomValidity('');


                    var visibleInput =
        if (value === '') {
                        getVisibleDateInput(
            return;
                            target
        }
                        );


                    if (visibleInput) {
        if (
                        window.setTimeout(
            !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
                            function () {
        ) {
                                visibleInput.focus();
            input.setCustomValidity(
                            },
                '提供数量は0以上の数値で入力してください(例:8、1、0.5)'
                            0
            );
                        );
        }
                    }
    }
                }
            },
            true
        );


        /*
    function validateLimitedQuantity(input) {
        * 「販売商品を追加」でDOMが増えた場合の初期化。
         var value = input.value.trim();
        */
         var mutationTimer = null;


         var observer =
         input.setCustomValidity('');
            new MutationObserver(
                function () {
                    window.clearTimeout(
                        mutationTimer
                    );


                    mutationTimer =
        if (
                        window.setTimeout(
            value !== '' &&
                            function () {
            !/^\d+$/.test(value)
                                initializeFields(
        ) {
                                    form
            input.setCustomValidity(
                                );
                 '限定数量は0以上の整数で入力してください(例:100)'
                            },
                            100
                        );
                 }
             );
             );
        }
    }


        observer.observe(
    function validateSortOrder(input) {
            form,
         var value = input.value.trim();
            {
                childList: true,
                subtree: true
            }
         );


         initializeFields(form);
         input.setCustomValidity('');
    }


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


     mw.hook(
     function validateSourceUrl(input) {
         'wikipage.content'
         var value = input.value.trim();
    ).add(
        setupOfferingValidation
    );


    mw.hook(
        input.setCustomValidity('');
        'pf.formSetupAfter'
    ).add(
        setupOfferingValidation
    );


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


        try {
            var url = new URL(value);


mw.loader.using('mediawiki.api').then(function () {
            if (
    'use strict';
                url.protocol !== 'http:' &&
 
                url.protocol !== 'https:'
    if (window.__festivalStallMenuFilterInitialized) {
            ) {
         return;
                input.setCustomValidity(
                    '情報元URLは http:// または https:// で始まるURLを入力してください。'
                );
            }
        } catch (e) {
            input.setCustomValidity(
                '情報元URLを正しいURL形式で入力してください。'
            );
         }
     }
     }


     window.__festivalStallMenuFilterInitialized = true;
     function validateLastConfirmed(input) {
        var value = input.value;
        var max = input.max;


    const STALL_SELECTOR =
         input.setCustomValidity('');
         'select[name="FestivalStallPlacement[stall_id]"]';


    const MENU_SELECTOR =
        if (
         'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
            value !== '' &&
            max !== '' &&
            value > max
         ) {
            input.setCustomValidity(
                '未来の日付は入力できません。' +
                max.replace(/-/g, '/') +
                '以前の日付を入力してください。'
            );
        }
    }


     const TEMPLATE_MENU_SELECTOR =
     function getVisibleDateInput(dateInput) {
        'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
        var widget =
            dateInput.closest('.oo-ui-widget');


    const api = new mw.Api();
        if (!widget) {
            return null;
        }


    let requestSerial = 0;
        return widget.querySelector(
    let observerTimer = null;
            'input[type="text"]'
     let applying = false;
        );
     }


     const menuCache = {};
     function getDateErrorElement(dateInput) {
        var widget =
            dateInput.closest('.oo-ui-widget');


/*
        if (!widget) {
* FestivalStallPlacement フォーム以外では
            return null;
* この連動機能を起動しない。
        }
*/
 
const stallSelect =
        var next =
    document.querySelector(STALL_SELECTOR);
            widget.nextElementSibling;


if (!stallSelect) {
        if (
    return;
            next &&
}
            next.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            return next;
        }


/*
        var error =
* Page Formsの雛形が持つ全商品optionを最初に保存
            document.createElement('div');
*/
const templateSelect =
    document.querySelector(TEMPLATE_MENU_SELECTOR);


if (!templateSelect) {
        /*
    console.error(
        * 既存の最終確認日エラー用CSSも利用する。
        '販売商品の雛形SELECTが見つかりません。'
        */
    );
        error.className =
    return;
            'stall-last-confirmed-error ' +
}
            'stall-offering-last-confirmed-error';


    const masterOptions =
         error.setAttribute(
         [...templateSelect.options].map(
             'role',
             function (option) {
             'alert'
                return option.cloneNode(true);
             }
         );
         );


    function cargoQuote(value) {
         error.hidden = true;
         return "'" + String(value)
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
    }


    function cargoRows(res) {
         widget.insertAdjacentElement(
         return (res.cargoquery || []).map(
             'afterend',
             function (row) {
             error
                return row.title || {};
             }
         );
         );
        return error;
     }
     }


     function getRealMenuSelects() {
     function showDateError(dateInput) {
         return [
         var visibleInput =
             ...document.querySelectorAll(
             getVisibleDateInput(dateInput);
                MENU_SELECTOR
            )
        ].filter(function (select) {
            return !select.name.includes('[num]');
        });
    }


    function resolveStallId(stallName) {
        var error =
            getDateErrorElement(dateInput);


         return api.get({
         if (!error) {
             action: 'cargoquery',
             return;
            format: 'json',
         }
            tables: 'Stalls',
            fields:
                'stall_id=stall_id,' +
                'name=name',
            where:
                'name=' +
                cargoQuote(stallName),
            limit: 20
         }).then(function (res) {


             const rows =
        var maxDate =
                 cargoRows(res);
             dateInput.max
                 ? dateInput.max.replace(/-/g, '/')
                : '';


             if (rows.length === 1) {
        if (
                 return rows[0].stall_id;
            dateInput.validity.rangeOverflow ||
            }
             (
                dateInput.value &&
                dateInput.max &&
                dateInput.value > dateInput.max
            )
        ) {
            error.textContent =
                '未来の日付は入力できません。' +
                maxDate +
                '以前の日付を入力してください。';
        } else {
            error.textContent =
                 dateInput.validationMessage ||
                '正しい日付を入力してください。';
        }


            /*
        error.hidden = false;
            * 同名表示が
            * 名前 (ID)
            * になっている場合
            */
            const match =
                String(stallName)
                    .match(/\((\d+)\)$/);


            if (!match) {
        if (visibleInput) {
                 throw new Error(
            visibleInput.setAttribute(
                    '屋台を1件に特定できません: ' +
                 'aria-invalid',
                    stallName
                'true'
                );
            );
             }
        }
    }
 
    function clearDateError(dateInput) {
        var visibleInput =
            getVisibleDateInput(dateInput);
 
        var widget =
             dateInput.closest('.oo-ui-widget');


            return match[1];
         var error = null;
         });
    }


    function loadMenus(stallId) {
        if (
            widget &&
            widget.nextElementSibling &&
            widget.nextElementSibling.classList.contains(
                'stall-offering-last-confirmed-error'
            )
        ) {
            error =
                widget.nextElementSibling;
        }


         const key =
         if (error) {
             String(stallId);
            error.hidden = true;
             error.textContent = '';
        }


         if (menuCache[key]) {
         if (visibleInput) {
             return Promise.resolve(
             visibleInput.removeAttribute(
                 menuCache[key]
                 'aria-invalid'
             );
             );
         }
         }
    }


        return api.get({
    function getLimitedQuantityInput(
            action: 'cargoquery',
        checkbox,
            format: 'json',
        form
            tables: 'StallMenuItems',
    ) {
            fields:
        if (!checkbox || !checkbox.name) {
                'menu_item_id=menu_item_id,' +
             return null;
                'stall_id=stall_id,' +
         }
                'name=name,' +
                'status=status,' +
                'sort_order=sort_order',
            where:
                'stall_id=' +
                Number(stallId) +
                " AND status='active'",
             order_by:
                'sort_order,menu_item_id',
            limit: 100
         }).then(function (res) {


             const rows =
        var quantityName =
                 cargoRows(res);
             checkbox.name.replace(
                /\[limited\]\[value\]$/,
                 '[limited_quantity]'
            );


             menuCache[key] =
        return Array.from(
                rows;
             form.querySelectorAll(
 
                'input[name^="FestivalStallMenuOffering["]'
             return rows;
            )
         });
        ).find(
             function (input) {
                return input.name === quantityName;
            }
         ) || null;
     }
     }


     function optionBelongsToMenu(
     function updateLimitedState(
         option,
         checkbox,
         menu
         form,
        clearWhenOff
     ) {
     ) {
 
         var quantityInput =
         const name =
             getLimitedQuantityInput(
             String(menu.name || '');
                checkbox,
 
                 form
        const id =
            String(
                 menu.menu_item_id || ''
             );
             );


         const value =
         if (!quantityInput) {
             String(option.value || '');
             return;
        }


         const text =
         if (checkbox.checked) {
             String(
            quantityInput.disabled = false;
                 option.textContent || ''
             quantityInput.removeAttribute(
                 'aria-disabled'
             );
             );
        } else {
            if (clearWhenOff) {
                quantityInput.value = '';
            }


         /*
            quantityInput.setCustomValidity('');
        * 商品名が一意
            quantityInput.disabled = true;
        */
            quantityInput.setAttribute(
                'aria-disabled',
                'true'
            );
         }
    }
 
    function validateField(element) {
         if (
         if (
             value === name ||
             !isOfferingField(element) ||
             text === name
             isTemplateField(element)
         ) {
         ) {
             return true;
             return;
         }
         }


         /*
         if (
        * Page Formsによる
            fieldNameEndsWith(
        * 同名商品の識別表示
                element,
        *
                '[price]'
        * たこ焼き (1)
            )
        * たこ焼き (3)
        ) {
        */
             validatePrice(element);
        const mapped =
            return;
             name + ' (' + id + ')';
        }


         return (
         if (
             value === mapped ||
             fieldNameEndsWith(
             text === mapped
                element,
         );
                '[serving_quantity]'
    }
             )
         ) {
            validateServingQuantity(element);
            return;
        }


    function makeOptions(menus) {
        if (
            fieldNameEndsWith(
                element,
                '[limited_quantity]'
            )
        ) {
            validateLimitedQuantity(element);
            return;
        }


         const options = [];
         if (
            fieldNameEndsWith(
                element,
                '[sort_order]'
            )
        ) {
            validateSortOrder(element);
            return;
        }


         /*
         if (
        * 空欄
            fieldNameEndsWith(
        */
                element,
        const blank =
                '[source_url]'
             masterOptions.find(
             )
                function (option) {
        ) {
                    return (
            validateSourceUrl(element);
                        option.value === ''
             return;
                    );
        }
                }
             );


         if (blank) {
         if (
             options.push(
             fieldNameEndsWith(
                 blank.cloneNode(true)
                 element,
             );
                '[last_confirmed]'
         } else {
             )
             options.push(
         ) {
                new Option('', '')
             validateLastConfirmed(element);
            );
        }


        menus.forEach(
            if (element.validity.valid) {
             function (menu) {
                clearDateError(element);
             }


                const option =
            return;
                    masterOptions.find(
        }
                        function (candidate) {
    }
                            return optionBelongsToMenu(
                                candidate,
                                menu
                            );
                        }
                    );


                if (option) {
    function initializeFields(form) {
                    options.push(
        /*
                        option.cloneNode(true)
        * 販売状態を日本語化。
                    );
        * [num]も変更しておくことで、
                 } else {
        * 後から追加されるmultipleにも反映される。
                     console.warn(
        */
                        'Page Formsのoptionを特定できません:',
        form.querySelectorAll(
                        menu
            'select[name^="FestivalStallMenuOffering["]' +
                    );
            '[name$="[availability]"]'
                }
        ).forEach(
            function (select) {
                 localizeSelect(
                     select,
                    availabilityLabels
                );
             }
             }
         );
         );


         return options;
         /*
    }
        * 確認状態を日本語化。
 
        */
    function optionSignature(select) {
        form.querySelectorAll(
 
            'select[name^="FestivalStallMenuOffering["]' +
         return [...select.options]
            '[name$="[verification_status]"]'
             .map(function (option) {
         ).forEach(
                 return (
             function (select) {
                     option.value +
                 localizeSelect(
                    '::' +
                     select,
                     option.textContent
                     verificationLabels
                 );
                 );
             })
             }
            .join('||');
        );
    }


    function filterMenuSelects(
        /*
        menus,
        * 数値入力向けキーボード。
         clearSelection
        */
    ) {
        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';
            }
        );


         const desiredTemplate =
         form.querySelectorAll(
             makeOptions(menus);
            'input[name^="FestivalStallMenuOffering["]' +
             '[name$="[serving_quantity]"]'
        ).forEach(
            function (input) {
                input.inputMode = 'decimal';
            }
        );


         const desiredSignature =
         form.querySelectorAll(
             desiredTemplate
            'input[name^="FestivalStallMenuOffering["]' +
                .map(function (option) {
             '[name$="[source_url]"]'
                    return (
        ).forEach(
                        option.value +
            function (input) {
                        '::' +
                input.inputMode = 'url';
                        option.textContent
            }
                    );
        );
                })
                .join('||');


         applying = true;
         /*
        * 限定数量欄のON/OFF。
        */
        form.querySelectorAll(
            'input[type="checkbox"]' +
            '[name^="FestivalStallMenuOffering["]' +
            '[name$="[limited][value]"]'
        ).forEach(
            function (checkbox) {
                updateLimitedState(
                    checkbox,
                    form,
                    false
                );
            }
        );


         getRealMenuSelects().forEach(
         /*
            function (select) {
        * 現在値を一度検証。
 
        * [num]は除外。
                const previousValue =
        */
                    select.value;
        form.querySelectorAll(
 
            '[name^="FestivalStallMenuOffering["]'
                /*
        ).forEach(
                * すでに正しい候補なら
            function (element) {
                * DOMを触らない
                validateField(element);
                */
            }
                if (
        );
                    optionSignature(select) ===
    }
                    desiredSignature
                ) {
                    if (clearSelection &&
                        select.value !== '') {


                        select.value = '';
    function setupOfferingValidation() {
        var form =
            document.getElementById(
                FORM_ID
            );


                        if (window.jQuery) {
        if (!form) {
                            jQuery(select)
            return;
                                .trigger('change');
        }
                        }
                    }


                    return;
        /*
                }
        * wikipage.content 等で再度呼ばれても
        * イベントを二重登録しない。
        */
        if (
            form.dataset
                .offeringValidationInitialized ===
            '1'
        ) {
            initializeFields(form);
            return;
        }


                const newOptions =
        form.dataset
                    desiredTemplate.map(
            .offeringValidationInitialized =
                        function (option) {
            '1';
                            return option
                                .cloneNode(true);
                        }
                    );


                 select.replaceChildren(
        /*
                     ...newOptions
        * multipleで後から追加された項目にも効くよう
                 );
        * form側でイベント委譲。
 
        */
                if (!clearSelection) {
        form.addEventListener(
            'input',
            function (event) {
                 validateField(
                     event.target
                 );
            }
        );


                    const exists =
        form.addEventListener(
                        [...select.options]
            'change',
                            .some(
            function (event) {
                                function (option) {
                var target =
                                    return (
                    event.target;
                                        option.value ===
                                        previousValue
                                    );
                                }
                            );


                    if (exists) {
                if (!isOfferingField(target)) {
                        select.value =
                    return;
                            previousValue;
                    }
                 }
                 }


                 if (clearSelection) {
                 if (
                     select.value = '';
                     target.type === 'checkbox' &&
                    fieldNameEndsWith(
                        target,
                        '[limited][value]'
                    )
                ) {
                    updateLimitedState(
                        target,
                        form,
                        true
                    );
                 }
                 }


                 if (window.jQuery) {
                 validateField(target);
                    jQuery(select)
                        .trigger('change');
                }
             }
             }
         );
         );


        /*
/*
        * MutationObserverに
* Page Forms のカレンダー選択では、
        * 自分自身の変更を拾わせない
* visible input に blur が発生する場合がある。
        */
* 対応する非表示 date input を取得して再検証する。
        setTimeout(
*/
            function () {
form.addEventListener(
                applying = false;
    'blur',
            },
    function (event) {
            0
var target = event.target;
        );
    }


     function refreshMenus(
if (
         clearSelection
    !target ||
    ) {
     typeof target.closest !== 'function'
) {
    return;
}
 
var widget =
    target.closest('.oo-ui-widget');
 
         if (!widget) {
            return;
        }


         const stall =
         var dateInput =
             document.querySelector(
             widget.querySelector(
                 STALL_SELECTOR
                 'input[type="date"]' +
                '[name^="FestivalStallMenuOffering["]' +
                '[name$="[last_confirmed]"]'
             );
             );


         if (
         if (
             !stall ||
             !dateInput ||
             !stall.value
             isTemplateField(dateInput)
         ) {
         ) {
             return;
             return;
         }
         }


         const serial =
         window.setTimeout(
             ++requestSerial;
             function () {
                validateField(dateInput);


         const stallName =
                if (dateInput.validity.valid) {
            stall.value;
                    clearDateError(dateInput);
                } else {
                    showDateError(dateInput);
                }
            },
            0
         );
    },
    true
);


         resolveStallId(
         /*
            stallName
        * invalidイベントは通常bubbleしないため
        )
        * capture=trueで取得する。
         .then(function (stallId) {
        */
         form.addEventListener(
            'invalid',
            function (event) {
                var target =
                    event.target;


            if (
                if (
                serial !==
                    !isOfferingField(target) ||
                 requestSerial
                    isTemplateField(target)
            ) {
                 ) {
                return null;
                    return;
            }
                }


            console.log(
                validateField(target);
                '[屋台→商品V2]',
                stallName,
                '→ stall_id=' +
                stallId
            );


            return loadMenus(
                if (
                stallId
                    fieldNameEndsWith(
            );
                        target,
 
                        '[last_confirmed]'
        })
                    )
        .then(function (menus) {
                ) {
                    event.preventDefault();


            if (
                    showDateError(target);
                !menus ||
                serial !==
                    requestSerial
            ) {
                return;
            }


            console.log(
                    var visibleInput =
                '[販売商品候補V2]',
                        getVisibleDateInput(
                menus
                            target
            );
                        );


            filterMenuSelects(
                    if (visibleInput) {
                menus,
                        window.setTimeout(
                 clearSelection
                            function () {
             );
                                visibleInput.focus();
                            },
                            0
                        );
                    }
                 }
             },
            true
        );


         })
         /*
         .catch(function (err) {
        * 「販売商品を追加」でDOMが増えた場合の初期化。
        */
         var mutationTimer = null;


             console.error(
        var observer =
                 '[屋台→商品V2] エラー:',
             new MutationObserver(
                err
                 function () {
            );
                    window.clearTimeout(
        });
                        mutationTimer
    }
                    );


    /*
                    mutationTimer =
    * Page Formsによる
                        window.setTimeout(
    * option再生成を検出
                            function () {
    */
                                initializeFields(
     function mutationTouchesMenus(
                                    form
         mutation
                                );
                            },
                            100
                        );
                }
            );
 
        observer.observe(
            form,
            {
                childList: true,
                subtree: true
            }
        );
 
        initializeFields(form);
    }
 
     if (
         document.readyState ===
        'loading'
     ) {
     ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupOfferingValidation
        );
    } else {
        setupOfferingValidation();
    }


         const target =
    mw.hook(
            mutation.target;
         'wikipage.content'
    ).add(
        setupOfferingValidation
    );


        if (
    mw.hook(
            target.nodeType === 1 &&
        'pf.formSetupAfter'
            target.matches &&
    ).add(
            target.matches(MENU_SELECTOR)
         setupOfferingValidation
         ) {
    );
            return true;
        }


        for (
})();
            const node of
            mutation.addedNodes
        ) {


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


            if (
mw.loader.using('mediawiki.api').then(function () {
                node.matches &&
    'use strict';
                node.matches(MENU_SELECTOR)
            ) {
                return true;
            }


            if (
    if (window.__festivalStallMenuFilterInitialized) {
                node.querySelector &&
        return;
                node.querySelector(
    }
                    MENU_SELECTOR
                )
            ) {
                return true;
            }


            /*
    window.__festivalStallMenuFilterInitialized = true;
            * SELECTの中にOPTIONが追加された
            */
            if (
                node.tagName === 'OPTION' &&
                node.parentElement &&
                node.parentElement.matches &&
                node.parentElement.matches(
                    MENU_SELECTOR
                )
            ) {
                return true;
            }
        }


         return false;
    const STALL_SELECTOR =
    }
         'select[name="FestivalStallPlacement[stall_id]"]';


     const observer =
     const MENU_SELECTOR =
         new MutationObserver(
         'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
            function (mutations) {


                if (applying) {
    const TEMPLATE_MENU_SELECTOR =
                    return;
        'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
                }


                const touched =
    const api = new mw.Api();
                    mutations.some(
                        mutationTouchesMenus
                    );


                if (!touched) {
    let requestSerial = 0;
                    return;
    let observerTimer = null;
                }
    let applying = false;


                clearTimeout(
    const menuCache = {};
                    observerTimer
                );


                /*
/*
                * Page Formsの再初期化が
* FestivalStallPlacement フォーム以外では
                * 完了してから実行
* この連動機能を起動しない。
                */
*/
                observerTimer =
const stallSelect =
                    setTimeout(
    document.querySelector(STALL_SELECTOR);
                        function () {
 
                            refreshMenus(false);
if (!stallSelect) {
                        },
    return;
                        250
}
                    );
            }
        );


    const form =
/*
        document.getElementById(
* Page Formsの雛形が持つ全商品optionを最初に保存
            'pfForm'
*/
        ) || document.body;
const templateSelect =
    document.querySelector(TEMPLATE_MENU_SELECTOR);


     observer.observe(
if (!templateSelect) {
         form,
     console.error(
        {
         '販売商品の雛形SELECTが見つかりません。'
            childList: true,
            subtree: true
        }
     );
     );
    return;
}


    /*
     const masterOptions =
    * 屋台変更
         [...templateSelect.options].map(
    */
            function (option) {
     const stall =
                return option.cloneNode(true);
         document.querySelector(
             }
             STALL_SELECTOR
         );
         );


     function onStallChange() {
     function cargoQuote(value) {
         refreshMenus(true);
         return "'" + String(value)
            .replace(/\\/g, '\\\\')
            .replace(/'/g, "\\'") + "'";
     }
     }


         stall.addEventListener(
    function cargoRows(res) {
        'change',
         return (res.cargoquery || []).map(
         onStallChange
            function (row) {
     );
                return row.title || {};
            }
         );
     }


     /*
     function getRealMenuSelects() {
    * 初期表示
        return [
    */
            ...document.querySelectorAll(
    refreshMenus(false);
                MENU_SELECTOR
            )
        ].filter(function (select) {
            return !select.name.includes('[num]');
        });
    }


        console.log(
    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 =
* StallMenuItem
                cargoRows(res);
* 入力検証・状態日本語化
*/
(function () {
    'use strict';


    function setupStallMenuItemValidation() {
            if (rows.length === 1) {
        const form = document.getElementById('pfForm');
                return rows[0].stall_id;
            }


        if (!form) {
            /*
             return;
            * 同名表示が
        }
            * 名前 (ID)
            * になっている場合
            */
             const match =
                String(stallName)
                    .match(/\((\d+)\)$/);


        /*
            if (!match) {
        * StallMenuItemフォーム以外では何もしない。
                throw new Error(
        */
                    '屋台を1件に特定できません: ' +
        const nameInput = form.querySelector(
                    stallName
            'input[name="StallMenuItem[name]"]'
                );
        );
            }


        if (!nameInput) {
             return match[1];
             return;
         });
         }
    }


        /*
    function loadMenus(stallId) {
        * 二重初期化防止
        */
        if (
            form.dataset.stallMenuItemValidationInitialized === '1'
        ) {
            return;
        }


         form.dataset.stallMenuItemValidationInitialized = '1';
         const key =
            String(stallId);


         /*
         if (menuCache[key]) {
        * =====================================
             return Promise.resolve(
        * 状態を日本語表示
                 menuCache[key]
        * =====================================
        */
        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];
                    }
                }
             );
             );
         }
         }


        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;


        console.log(
             return rows;
             '商品マスター入力チェックを初期化しました。'
         });
        );
    }
 
    if (document.readyState === 'loading') {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemValidation
         );
    } else {
        setupStallMenuItemValidation();
     }
     }


     mw.hook('wikipage.content').add(
     function optionBelongsToMenu(
         setupStallMenuItemValidation
         option,
     );
        menu
     ) {


})();
        const name =
            String(menu.name || '');


/*
        const id =
* StallMenuItem
            String(
* 同一屋台 + 同一商品名の重複警告
                menu.menu_item_id || ''
*
            );
* 保存は禁止しない。
*/
mw.loader.using([
    'mediawiki.api',
    'mediawiki.util'
]).then(function () {
    'use strict';


    const api = new mw.Api();
        const value =
            String(option.value || '');


    function cargoQuote(value) {
        const text =
        return "'" + String(value)
            String(
            .replace(/\\/g, '\\\\')
                option.textContent || ''
             .replace(/'/g, "\\'") + "'";
             );
    }


    function cargoRows(response) {
        /*
         return (response.cargoquery || []).map(
        * 商品名が一意
             function (row) {
        */
                return row.title || row;
         if (
            }
            value === name ||
         );
             text === name
    }
        ) {
            return true;
        }
 
        /*
        * Page Formsによる
        * 同名商品の識別表示
        *
        * たこ焼き (1)
        * たこ焼き (3)
        */
         const mapped =
            name + ' (' + id + ')';


    function cargoQuery(
         return (
        tables,
             value === mapped ||
        fields,
             text === mapped
        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) {
     function makeOptions(menus) {
        return String(value || '')
            .replace(/_/g, ' ')
            .trim();
    }


    function setupStallMenuItemDuplicateWarning() {
         const options = [];
         const form =
            document.getElementById('pfForm');
 
        if (!form) {
            return;
        }


         /*
         /*
         * StallMenuItemフォームだけを対象にする。
         * 空欄
         */
         */
         const stallSelect = form.querySelector(
         const blank =
            'select[name="StallMenuItem[stall_id]"]'
            masterOptions.find(
        );
                function (option) {
                    return (
                        option.value === ''
                    );
                }
            );


         const nameInput = form.querySelector(
         if (blank) {
             'input[name="StallMenuItem[name]"]'
            options.push(
         );
                blank.cloneNode(true)
 
             );
        if (!stallSelect || !nameInput) {
         } else {
             return;
            options.push(
                new Option('', '')
             );
         }
         }


         /*
         menus.forEach(
        * 二重初期化防止
             function (menu) {
        */
        if (
             form.dataset
                .stallMenuItemDuplicateWarning ===
            '1'
        ) {
            return;
        }


        form.dataset
                const option =
            .stallMenuItemDuplicateWarning =
                    masterOptions.find(
            '1';
                        function (candidate) {
                            return optionBelongsToMenu(
                                candidate,
                                menu
                            );
                        }
                    );


        /*
                if (option) {
        * 警告表示欄
                    options.push(
        */
                        option.cloneNode(true)
        const warning =
                    );
            document.createElement('div');
                } else {
                    console.warn(
                        'Page Formsのoptionを特定できません:',
                        menu
                    );
                }
            }
        );


         warning.className =
         return options;
            'stall-menu-item-duplicate-warning';
    }


        warning.setAttribute(
    function optionSignature(select) {
            'role',
            'status'
        );


         warning.hidden = true;
         return [...select.options]
            .map(function (option) {
                return (
                    option.value +
                    '::' +
                    option.textContent
                );
            })
            .join('||');
    }


        warning.style.marginTop = '8px';
    function filterMenuSelects(
         warning.style.padding = '10px';
         menus,
         warning.style.border = '1px solid #a2a9b1';
         clearSelection
        warning.style.borderRadius = '4px';
    ) {


         const container =
         const desiredTemplate =
             nameInput.closest('td') ||
             makeOptions(menus);
            nameInput.parentNode;


         container.appendChild(warning);
         const desiredSignature =
            desiredTemplate
                .map(function (option) {
                    return (
                        option.value +
                        '::' +
                        option.textContent
                    );
                })
                .join('||');


         let timer = null;
         applying = true;
        let requestSerial = 0;


         /*
         getRealMenuSelects().forEach(
        * Page Formsのmappingでは
            function (select) {
        * SELECT.valueが屋台名になる場合があるため、
        * Cargoからstall_idを解決する。
        */
        function resolveStallId() {
            const rawValue =
                String(
                    stallSelect.value || ''
                ).trim();


            if (!rawValue) {
                const previousValue =
                return Promise.resolve('');
                    select.value;
            }


            /*
                /*
            * 数値ならそのまま使用。
                * すでに正しい候補なら
            */
                * DOMを触らない
            if (/^\d+$/.test(rawValue)) {
                */
                return Promise.resolve(
                if (
                    rawValue
                    optionSignature(select) ===
                );
                    desiredSignature
            }
                ) {
                    if (clearSelection &&
                        select.value !== '') {


            const selectedOption =
                        select.value = '';
                stallSelect.options[
                    stallSelect.selectedIndex
                ];


            const selectedText =
                        if (window.jQuery) {
                selectedOption
                            jQuery(select)
                    ? selectedOption.textContent.trim()
                                .trigger('change');
                     : '';
                        }
                     }


            const names = [];
                    return;
                }


            if (rawValue) {
                const newOptions =
                names.push(rawValue);
                    desiredTemplate.map(
            }
                        function (option) {
                            return option
                                .cloneNode(true);
                        }
                    );


            if (
                 select.replaceChildren(
                selectedText &&
                    ...newOptions
                 names.indexOf(selectedText) === -1
                 );
            ) {
                 names.push(selectedText);
            }


            if (names.length === 0) {
                if (!clearSelection) {
                return Promise.resolve('');
            }


            const where = names.map(
                    const exists =
                function (name) {
                        [...select.options]
                    return (
                            .some(
                        'name=' +
                                function (option) {
                        cargoQuote(name)
                                    return (
                    );
                                        option.value ===
                }
                                        previousValue
            ).join(' OR ');
                                    );
                                }
                            );


            return cargoQuery(
                     if (exists) {
                'Stalls',
                        select.value =
                'stall_id=stall_id,' +
                            previousValue;
                     'name=stall_name',
                where,
                10
            ).then(
                function (rows) {
                    if (!rows.length) {
                        return '';
                     }
                     }
                }


                    return String(
                if (clearSelection) {
                        rows[0].stall_id || ''
                    select.value = '';
                    );
                 }
                 }
            );
        }


        function clearWarning() {
                if (window.jQuery) {
            warning.hidden = true;
                    jQuery(select)
            warning.textContent = '';
                        .trigger('change');
         }
                }
            }
         );


         function showWarning(rows) {
         /*
             warning.textContent = '';
        * MutationObserverに
        * 自分自身の変更を拾わせない
        */
        setTimeout(
            function () {
                applying = false;
            },
             0
        );
    }


            const title =
    function refreshMenus(
                document.createElement('strong');
        clearSelection
    ) {


             title.textContent =
        const stall =
                 '同じ屋台に同名の商品がすでに登録されています。';
             document.querySelector(
                 STALL_SELECTOR
            );


            warning.appendChild(title);
if (!stall) {
    return;
}


            const text =
if (!stall.value) {
                document.createElement('div');
    /*
    * 屋台が未選択なら、
    * 進行中の古い非同期処理を無効化し、
    * 商品候補を空欄だけに戻す。
    */
    ++requestSerial;


            text.textContent =
    filterMenuSelects(
                '重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
        [],
        true
    );


            warning.appendChild(text);
    return;
}


            const list =
        const serial =
                document.createElement('ul');
            ++requestSerial;


            rows.forEach(
        const stallName =
                function (row) {
            stall.value;
                    const item =
                        document.createElement('li');


                    const link =
        resolveStallId(
                        document.createElement('a');
            stallName
        )
        .then(function (stallId) {


                    link.href =
            if (
                        mw.util.getUrl(
                serial !==
                            row.page_name
                requestSerial
                        );
            ) {
                return null;
            }


                    link.textContent =
            console.log(
                        (
                '[屋台→商品V2]',
                            row.menu_name ||
                stallName,
                            '商品'
                '→ stall_id=' +
                        ) +
                stallId
                        '(商品ID: ' +
            );
                        row.menu_item_id +
                        ')';


                    link.target = '_blank';
            return loadMenus(
 
                 stallId
                    item.appendChild(link);
                    list.appendChild(item);
                 }
             );
             );


            warning.appendChild(list);
         })
            warning.hidden = false;
         .then(function (menus) {
         }
 
         function checkDuplicate() {
            const menuName =
                nameInput.value.trim();


             if (
             if (
                 !stallSelect.value ||
                 !menus ||
                 !menuName
                 serial !==
                    requestSerial
             ) {
             ) {
                clearWarning();
                 return;
                 return;
             }
             }


             const currentRequest =
             console.log(
                 ++requestSerial;
                 '[販売商品候補V2]',
                menus
            );
 
            filterMenuSelects(
                menus,
                clearSelection
            );


            resolveStallId().then(
        })
                function (stallId) {
        .catch(function (err) {
                    if (
                        currentRequest !==
                        requestSerial
                    ) {
                        return null;
                    }


                    if (!stallId) {
            console.error(
                        clearWarning();
                '[屋台→商品V2] エラー:',
                        return null;
                err
                    }
            );
        });
    }


                    return cargoQuery(
    /*
                        'StallMenuItems',
    * Page Formsによる
                        'menu_item_id=menu_item_id,' +
    * option再生成を検出
                            'name=menu_name,' +
    */
                            '_pageName=page_name',
    function mutationTouchesMenus(
                        'stall_id=' +
        mutation
                            stallId +
    ) {
                            ' AND name=' +
                            cargoQuote(
                                menuName
                            ),
                        20
                    );
                }
            ).then(
                function (rows) {
                    if (
                        rows === null ||
                        rows === undefined
                    ) {
                        return;
                    }


                    if (
        const target =
                        currentRequest !==
            mutation.target;
                        requestSerial
                    ) {
                        return;
                    }


                    /*
        if (
                    * 編集画面では
            target.nodeType === 1 &&
                    * 自分自身を重複候補から除外。
            target.matches &&
                    */
            target.matches(MENU_SELECTOR)
                    const currentPage =
        ) {
                        normalizePageName(
            return true;
                            mw.config.get(
        }
                                'wgPageName'
                            )
                        );


                    const duplicates =
        for (
                        rows.filter(
            const node of
                            function (row) {
            mutation.addedNodes
                                return (
        ) {
                                    normalizePageName(
                                        row.page_name
                                    ) !==
                                    currentPage
                                );
                            }
                        );


                    if (
            if (
                        duplicates.length === 0
                node.nodeType !== 1
                    ) {
            ) {
                        clearWarning();
                continue;
                        return;
            }
                    }


                     showWarning(
            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
                         duplicates
                     );
                     );
                 }
                 }
             ).catch(
             ).catch(
                 function (error) {
                 function (error) {
                     console.error(
                     console.error(
                         '商品重複確認に失敗しました。',
                         '商品重複確認に失敗しました。',
                         error
                         error
                     );
                     );
 
 
                     clearWarning();
                     clearWarning();
                 }
                 }
             );
             );
         }
         }
 
 
         function scheduleCheck() {
         function scheduleCheck() {
             window.clearTimeout(timer);
             window.clearTimeout(timer);
 
 
             timer =
             timer =
                 window.setTimeout(
                 window.setTimeout(
                     checkDuplicate,
                     checkDuplicate,
                     300
                     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();
    }


         stallSelect.addEventListener(
    if (mw.hook) {
             'change',
         mw.hook(
             scheduleCheck
             'pf.formSetupAfter'
        ).add(
             installNamePrefill
         );
         );


         nameInput.addEventListener(
         mw.hook(
             'input',
             'wikipage.content'
            scheduleCheck
         ).add(
        );
             installNamePrefill
 
        nameInput.addEventListener(
            'change',
            scheduleCheck
         );
 
        /*
        * 編集画面で既存値が入っている場合にも確認。
        */
        scheduleCheck();
 
        console.log(
             '商品重複警告を初期化しました。'
         );
         );
     }
     }
 
}());
    if (
/* === R16 Venue/Festival new-form name autofill END === */
        document.readyState ===
        'loading'
    ) {
        document.addEventListener(
            'DOMContentLoaded',
            setupStallMenuItemDuplicateWarning
        );
    } else {
        setupStallMenuItemDuplicateWarning();
    }
 
    mw.hook(
        'wikipage.content'
    ).add(
        setupStallMenuItemDuplicateWarning
    );
 
});

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 === */