編集の要約なし |
安全な画像アップロードでファイル名の警告理由を表示 |
||
| (3人の利用者による、間の91版が非表示) | |||
| 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; | return true; | ||
} | } | ||
/* | |||
* 座標付きplacementが0件なら | |||
* marker indexは0件で正常完了 | |||
*/ | |||
if ( | if ( | ||
mapPlacementIds.length === 0 | |||
) { | ) { | ||
return false; | |||
festivalMapMarkerIndexReady = | |||
true; | |||
return true; | |||
} | |||
if ( | |||
!Array.isArray( | |||
window.mapsLeafletList | |||
) | |||
) { | |||
return false; | |||
} | } | ||
| 2,057行目: | 2,151行目: | ||
const expectedIds = | const expectedIds = | ||
new Set( | new Set( | ||
mapPlacementIds.map( | |||
String | String | ||
) | ) | ||
| 2,173行目: | 2,267行目: | ||
} | } | ||
); | ); | ||
if ( | |||
festivalMapMarkerIndexReady | |||
) { | |||
scheduleFestivalMapInitialViewport(); | |||
} | |||
| 2,182行目: | 2,283行目: | ||
/* ===================================== | /* | ||
* | * ===================================== | ||
* ===================================== */ | * 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 | 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( | .map( | ||
String | String | ||
) | |||
.map( | |||
function ( | |||
placementId | |||
) { | |||
return ( | |||
festivalMapMarkerIndex[ | |||
placementId | |||
] || | |||
null | |||
); | |||
} | |||
) | |||
.filter( | |||
function ( item ) { | |||
return Boolean( | |||
item && | |||
item.marker && | |||
typeof item.marker | |||
.getLatLng === | |||
'function' && | |||
item.markerLayer | |||
); | |||
} | |||
); | ); | ||
/* | |||
* mapPlacementIdsが存在する状態で | |||
* itemsが0件なのは初期化途中。 | |||
* | |||
* applied=trueにはせず、 | |||
* schedulerへfalseを返して再試行させる。 | |||
*/ | |||
if ( | if ( | ||
items.length === 0 | |||
) { | ) { | ||
return false; | |||
} | |||
const map = | |||
items[ | |||
0 | |||
].markerLayer && | |||
items[ | |||
0 | |||
].markerLayer._map | |||
? items[ | |||
0 | |||
].markerLayer._map | |||
: null; | |||
return; | 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 | |||
function | ); | ||
} | |||
) { | ) | ||
.map( | |||
function ( item ) { | |||
return item.marker | |||
.getLatLng(); | |||
} | |||
) | |||
.filter( | |||
function ( latlng ) { | |||
return Boolean( | |||
latlng && | |||
Number.isFinite( | |||
Number( | |||
latlng.lat | |||
) | |||
) && | |||
Number.isFinite( | |||
Number( | |||
latlng.lng | |||
) | |||
) | |||
); | |||
} | |||
); | |||
if ( | if ( | ||
latLngs.length === 0 | |||
) { | ) { | ||
return false; | return false; | ||
| 2,428行目: | 2,661行目: | ||
if ( | 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 ( | 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 ( | 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 ( | if ( | ||
mapIndexAttempts >= | |||
MAX_MAP_INDEX_ATTEMPTS | |||
) { | ) { | ||
console.warn( | |||
'祭り屋台地図:placement_idとmarkerを対応付けできませんでした。' | |||
); | |||
return; | 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 | const item = | ||
festivalMapMarkerIndex[ | |||
id | |||
]; | |||
if ( | |||
!item || | |||
!item.marker | |||
) { | |||
console.warn( | |||
'地図markerが見つかりません:', | |||
id | |||
' | |||
); | ); | ||
return false; | |||
} | |||
const marker = | |||
item.marker; | |||
/* | /* | ||
* 万一markerが非表示なら | |||
* 地図へ戻す | |||
*/ | |||
function | 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 | |||
const | .placementId || | ||
'' | |||
); | |||
/ | if ( | ||
!/^\d+$/.test( | |||
placementId | |||
) || | |||
placementId === '0' | |||
) { | |||
return; | |||
} | |||
/* | |||
* 座標なしplacementには | |||
* 地図ボタンを表示しない | |||
*/ | |||
if ( | |||
!mapPlacementIds.includes( | |||
placementId | |||
) | |||
) { | |||
return; | |||
} | |||
const | 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 | function createFilterSelect( | ||
labelText, | |||
className, | |||
allText | |||
) { | ) { | ||
const | 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 ( | 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 []; | |||
} | } | ||
| 4,221行目: | 4,410行目: | ||
function | /* ===================================== | ||
* 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 | return result; | ||
} | } | ||
| 4,359行目: | 4,548行目: | ||
/* ===================================== | /* ===================================== | ||
* | * 表示ヘルパー | ||
* ===================================== */ | * ===================================== */ | ||
function | 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 | const container = | ||
document.createElement( | 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( | document.createElement( | ||
' | 'div' | ||
); | ); | ||
wrapper.className = | |||
'stall-compare- | 'stall-compare-table-wrapper'; | ||
const table = | |||
document.createElement( | |||
'table' | |||
); | |||
table.className = | |||
'stall-compare-table'; | |||
/* ------------------------------ | |||
* thead | |||
* ------------------------------ */ | |||
const thead = | |||
document.createElement( | |||
'thead' | |||
); | |||
const headerRow = | |||
const | |||
document.createElement( | 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' | |||
); | |||
const | 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 | |||
data. | |||
); | ); | ||
} | |||
); | |||
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. | 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 | function markBestPrices( | ||
compareData | compareData | ||
) { | ) { | ||
const | const allMenus = []; | ||
/* ================================= | /* ================================= | ||
* | * 比較文字列を正規化 | ||
* | |||
* 例: | |||
* "たこ焼き" | |||
* " たこ焼き " | |||
* | |||
* を同じものとして扱う | |||
* ================================= */ | * ================================= */ | ||
function | function normalizeCompareText( | ||
value | value | ||
) { | ) { | ||
| 6,109行目: | 6,211行目: | ||
).trim(); | ).trim(); | ||
/* | |||
* 全角・半角などを可能な範囲で統一 | |||
*/ | |||
if ( | if ( | ||
typeof text.normalize === | typeof text.normalize === | ||
| 6,122行目: | 6,226行目: | ||
} | } | ||
/* | |||
* 連続空白を1つにする | |||
*/ | |||
text = | text = | ||
text.replace( | text.replace( | ||
| 6,128行目: | 6,234行目: | ||
' ' | ' ' | ||
); | ); | ||
/* | |||
* 英字商品名にも対応 | |||
*/ | |||
text = | |||
text.toLowerCase(); | |||
return text; | return text; | ||
| 6,133行目: | 6,245行目: | ||
} | } | ||
/* ================================= | |||
* 全メニューを集める | |||
* ================================= */ | |||
compareData.forEach( | |||
function ( data ) { | |||
if ( | if ( | ||
!data.menus || | |||
!Array.isArray( | |||
data.menus | |||
) | |||
) { | ) { | ||
return; | return; | ||
| 6,172行目: | 6,263行目: | ||
data.menus.forEach( | |||
function ( menu ) { | |||
/* | |||
* 毎回初期化 | |||
*/ | |||
menu.isLowestPrice = | |||
false; | |||
menu.isLowestUnitPrice = | |||
false; | |||
/* | |||
} | * 比較用の商品名 | ||
); | */ | ||
menu.compareMenuName = | |||
normalizeCompareText( | |||
menu.menuName | |||
); | |||
/* | |||
* 比較用単位 | |||
*/ | |||
menu.compareUnit = | |||
normalizeCompareText( | |||
menu.servingUnit | |||
); | |||
allMenus.push( | |||
menu | |||
); | |||
} | |||
); | |||
} | } | ||
| 6,191行目: | 6,305行目: | ||
/* ================================= | |||
function ( | * 商品名+単位ごとのグループ | ||
* | |||
* 例: | |||
* | |||
* たこ焼き + 個 | |||
* 焼きそば + パック | |||
* りんご飴 + 本 | |||
* ================================= */ | |||
const groups = {}; | |||
allMenus.forEach( | |||
function ( menu ) { | |||
/* | /* | ||
* | * 商品名が無ければ比較しない | ||
*/ | */ | ||
if ( | if ( | ||
!menu.compareMenuName | |||
) { | ) { | ||
return; | |||
} | } | ||
/* | /* | ||
* | * 単位が無ければ比較しない | ||
* | * | ||
* 「同じ商品名+同じ単位」 | |||
* が条件だから | |||
*/ | */ | ||
if ( | if ( | ||
!menu.compareUnit | |||
) { | ) { | ||
return; | |||
} | |||
const groupKey = | |||
menu.compareMenuName + | |||
'||' + | |||
menu.compareUnit; | |||
if ( | |||
!groups[ | |||
groupKey | |||
] | |||
) { | |||
groups[ | |||
groupKey | |||
] = []; | |||
} | |||
groups[ | |||
groupKey | |||
].push( | |||
menu | |||
); | |||
} | } | ||
); | ); | ||
/* ================================= | /* ================================= | ||
* | * グループごとに判定 | ||
* ================================= */ | * ================================= */ | ||
const groups = { | Object.keys( | ||
groups | |||
).forEach( | |||
function ( groupKey ) { | |||
const menus = | |||
groups[ | |||
groupKey | |||
]; | |||
/* ============================= | |||
* 2出店以上あるか確認 | |||
* | |||
* 同じ出店内だけの商品比較は | |||
* 「最安」としない | |||
* ============================= */ | |||
const placementIds = | |||
[ | |||
...new Set( | |||
menus.map( | |||
function ( menu ) { | |||
return String( | |||
menu.placementId | |||
); | |||
} | |||
) | |||
) | |||
]; | |||
if ( | if ( | ||
placementIds.length < 2 | |||
) { | ) { | ||
return; | 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 ) { | |||
const | /* | ||
* 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 ( | if ( | ||
stall.page | |||
) { | ) { | ||
const | const link = | ||
document.createElement( | 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 ( | 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 | |||
if ( | ) { | ||
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 | |||
); | |||
validateAccuracy(); | } | ||
} | |||
/* ============================= | |||
* 最安単位価格 | |||
* ============================= */ | |||
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: | |||
'© 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: | |||
'© 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: | |||
'© OpenStreetMap contributors' | |||
} | |||
).addTo(map); | |||
let marker = null; | |||
let venueRequestId = 0; | |||
const venueStatus = | |||
document.createElement( | |||
'div' | |||
); | |||
venueStatus.className = | |||
'matsuri-venue-location-status'; | |||
venueStatus.setAttribute( | |||
'aria-live', | |||
'polite' | |||
); | |||
venueStatus.style.marginBottom = | |||
'8px'; | |||
if (mapDiv.parentNode) { | |||
mapDiv.parentNode.insertBefore( | |||
venueStatus, | |||
mapDiv | |||
); | |||
} | |||
function setVenueStatus(message) { | |||
venueStatus.textContent = | |||
message; | |||
} | |||
function resetVenueView() { | |||
map.setView( | |||
[ 36.2048, 138.2529 ], | |||
5 | |||
); | |||
} | |||
function dispatchInputEvents(input) { | |||
input.dispatchEvent( | |||
new Event( | |||
'input', | |||
{ bubbles: true } | |||
) | |||
); | |||
input.dispatchEvent( | |||
new Event( | |||
'change', | |||
{ bubbles: true } | |||
) | |||
); | |||
} | |||
function updateInputs(lat, lon) { | |||
latInput.value = | |||
Number(lat).toFixed(6); | |||
lonInput.value = | |||
Number(lon).toFixed(6); | |||
/* | |||
* 既存の必須・日本範囲チェックを | |||
* そのまま発火させる。 | |||
*/ | |||
dispatchInputEvents(latInput); | |||
dispatchInputEvents(lonInput); | |||
} | |||
/* | |||
* R10-5C ISSUE-06B: | |||
* PageForms配下のLeaflet default PNGは | |||
* この環境ではHTMLへredirectされるため、 | |||
* 外部画像に依存しないdivIconを使用。 | |||
*/ | |||
const placementMarkerIcon = | |||
L.divIcon({ | |||
className: | |||
'matsuri-temporary-facility-marker-icon', | |||
html: | |||
'<svg xmlns="http://www.w3.org/2000/svg" ' + | |||
'width="28" height="42" viewBox="0 0 28 42" ' + | |||
'aria-hidden="true" focusable="false">' + | |||
'<path d="M14 1C6.8 1 1 6.8 1 14c0 10 13 27 13 27s13-17 13-27C27 6.8 21.2 1 14 1Z" ' + | |||
'fill="#2a81cb" stroke="#ffffff" stroke-width="2"/>' + | |||
'<circle cx="14" cy="14" r="5" fill="#ffffff"/>' + | |||
'</svg>', | |||
iconSize: | |||
[ | |||
28, | |||
42 | |||
], | |||
iconAnchor: | |||
[ | |||
14, | |||
40 | |||
] | |||
}); | |||
function createMarker(latlng) { | |||
marker = L.marker( | |||
latlng, | |||
{ | |||
draggable: | |||
true, | |||
icon: | |||
placementMarkerIcon | |||
} | |||
).addTo(map); | |||
marker.on( | |||
'dragend', | |||
function () { | |||
const position = | |||
marker.getLatLng(); | |||
updateInputs( | |||
position.lat, | |||
position.lng | |||
); | |||
} | |||
); | |||
} | |||
function placeMarker(latlng) { | |||
if (marker) { | |||
marker.setLatLng(latlng); | |||
} else { | |||
createMarker(latlng); | |||
} | |||
updateInputs( | |||
latlng.lat, | |||
latlng.lng | |||
); | |||
} | |||
function removeMarker() { | |||
if (!marker) { | |||
return; | |||
} | |||
map.removeLayer(marker); | |||
marker = null; | |||
} | |||
function clearCoordinates() { | |||
latInput.value = ''; | |||
lonInput.value = ''; | |||
dispatchInputEvents(latInput); | |||
dispatchInputEvents(lonInput); | |||
} | |||
function getCurrentCoordinates() { | |||
const lat = | |||
Number(latInput.value); | |||
const lon = | |||
Number(lonInput.value); | |||
if ( | |||
latInput.value.trim() === '' || | |||
lonInput.value.trim() === '' || | |||
Number.isNaN(lat) || | |||
Number.isNaN(lon) | |||
) { | |||
return null; | |||
} | |||
return { | |||
lat: lat, | |||
lng: lon | |||
}; | |||
} | |||
function escapeCargoValue(value) { | |||
return String(value) | |||
.replace( | |||
/'/g, | |||
"''" | |||
); | |||
} | |||
/* | |||
* 選択されたVenueの座標へ | |||
* 地図だけ移動する。 | |||
* | |||
* Placementのlatitude/longitudeには | |||
* コピーしない。 | |||
*/ | |||
function centerOnVenue() { | |||
const currentRequest = | |||
++venueRequestId; | |||
const venuePage = | |||
venueSelect.value.trim(); | |||
if (venuePage === '') { | |||
resetVenueView(); | |||
setVenueStatus( | |||
'会場は未指定です。' + | |||
'地図上で場所を指定できます。' | |||
); | |||
return; | |||
} | |||
setVenueStatus( | |||
'選択した会場の位置情報を確認しています。' | |||
); | |||
api.get({ | |||
action: 'cargoquery', | |||
format: 'json', | |||
tables: 'Venues', | |||
fields: | |||
'venue_id=venue_id,' + | |||
'_pageName=page_name,' + | |||
'latitude=latitude,' + | |||
'longitude=longitude', | |||
where: | |||
"_pageName='" + | |||
escapeCargoValue( | |||
venuePage | |||
) + | |||
"'", | |||
limit: '1' | |||
}).then(function (data) { | |||
/* | |||
* 連続して会場を変更した場合、 | |||
* 古いレスポンスを無視する。 | |||
*/ | |||
if ( | |||
currentRequest !== | |||
venueRequestId | |||
) { | |||
return; | |||
} | |||
const result = | |||
data && | |||
Array.isArray( | |||
data.cargoquery | |||
) | |||
? data.cargoquery | |||
: []; | |||
if (result.length === 0) { | |||
resetVenueView(); | |||
setVenueStatus( | |||
'会場情報を取得できませんでした。' + | |||
'地図上で場所を指定できます。' | |||
); | |||
console.warn( | |||
'会場情報を取得できませんでした。', | |||
venuePage | |||
); | |||
return; | |||
} | |||
const row = | |||
result[0].title || | |||
result[0]; | |||
const lat = | |||
Number(row.latitude); | |||
const lon = | |||
Number(row.longitude); | |||
if ( | |||
row.latitude === undefined || | |||
row.latitude === null || | |||
String( | |||
row.latitude | |||
).trim() === '' || | |||
row.longitude === undefined || | |||
row.longitude === null || | |||
String( | |||
row.longitude | |||
).trim() === '' || | |||
Number.isNaN(lat) || | |||
Number.isNaN(lon) | |||
) { | |||
resetVenueView(); | |||
setVenueStatus( | |||
'この会場は位置情報未登録です。' + | |||
'地図上で場所を指定できます。' | |||
); | |||
console.warn( | |||
'選択した会場には座標が登録されていません。', | |||
venuePage | |||
); | |||
return; | |||
} | |||
map.setView( | |||
[ lat, lon ], | |||
18 | |||
); | |||
setVenueStatus( | |||
'選択した会場の位置を表示しています。' + | |||
'必要に応じて地図上で実際の位置を指定してください。' | |||
); | |||
console.log( | |||
'会場位置へ地図を移動しました。', | |||
{ | |||
venue: venuePage, | |||
latitude: lat, | |||
longitude: lon | |||
} | |||
); | |||
}).catch(function (error) { | |||
if ( | |||
currentRequest !== | |||
venueRequestId | |||
) { | |||
return; | |||
} | |||
resetVenueView(); | |||
setVenueStatus( | |||
'会場位置の取得に失敗しました。' + | |||
'地図上で場所を指定できます。' | |||
); | |||
console.error( | |||
'会場座標の取得に失敗しました。', | |||
error | |||
); | |||
}); | |||
} | |||
/* | |||
* 地図クリック | |||
*/ | |||
map.on( | |||
'click', | |||
function (event) { | |||
placeMarker( | |||
event.latlng | |||
); | |||
} | |||
); | |||
/* | |||
* 手入力された場合もピンを同期。 | |||
*/ | |||
function syncMarkerFromInputs() { | |||
const coordinates = | |||
getCurrentCoordinates(); | |||
if (!coordinates) { | |||
return; | |||
} | |||
/* | |||
* User-selected / manually-entered coordinates | |||
* take priority over a late Venue response. | |||
*/ | |||
venueRequestId += 1; | |||
setVenueStatus( | |||
'指定した位置を地図に表示しています。' | |||
); | |||
if (marker) { | |||
marker.setLatLng( | |||
coordinates | |||
); | |||
} else { | |||
createMarker( | |||
coordinates | |||
); | |||
} | |||
map.setView( | |||
[ | |||
coordinates.lat, | |||
coordinates.lng | |||
], | |||
18 | |||
); | |||
} | |||
latInput.addEventListener( | |||
'change', | |||
syncMarkerFromInputs | |||
); | |||
lonInput.addEventListener( | |||
'change', | |||
syncMarkerFromInputs | |||
); | |||
/* | |||
* 会場を変更した場合。 | |||
* | |||
* 前の会場用の屋台座標を | |||
* 誤って残さないようクリアする。 | |||
*/ | |||
venueSelect.addEventListener( | |||
'change', | |||
function (event) { | |||
const preservePlacementCoordinates = | |||
!!( | |||
event && | |||
event.detail && | |||
event.detail | |||
.matsuriPreservePlacementCoordinates === | |||
true | |||
); | |||
/* | |||
* Even when the new Venue is blank, | |||
* invalidate an older Cargo response. | |||
*/ | |||
venueRequestId += 1; | |||
if ( | |||
preservePlacementCoordinates | |||
) { | |||
/* | |||
* Existing Placement coordinates | |||
* take priority over Venue center. | |||
* | |||
* If there are no Placement | |||
* coordinates, Venue is still a | |||
* useful map starting point. | |||
*/ | |||
if ( | |||
!getCurrentCoordinates() | |||
) { | |||
centerOnVenue(); | |||
} | |||
return; | |||
} | |||
removeMarker(); | |||
clearCoordinates(); | |||
centerOnVenue(); | |||
} | |||
); | |||
/* | |||
* 編集時: | |||
* 既存Placement座標を優先。 | |||
* | |||
* 新規時: | |||
* Venue座標へ地図を移動。 | |||
*/ | |||
const initialCoordinates = | |||
getCurrentCoordinates(); | |||
if (initialCoordinates) { | |||
createMarker( | |||
initialCoordinates | |||
); | |||
setVenueStatus( | |||
'登録済みの位置を地図に表示しています。' | |||
); | |||
map.setView( | |||
[ | |||
initialCoordinates.lat, | |||
initialCoordinates.lng | |||
], | |||
18 | |||
); | |||
} else { | |||
centerOnVenue(); | |||
} | |||
/* | |||
* R10-5C ISSUE-06A: | |||
* 折りたたみ中はmapが0x0なので、 | |||
* 実際に展開された後でも | |||
* Leafletの内部サイズを再計算する。 | |||
*/ | |||
const mapCollapsible = | |||
mapDiv.closest( | |||
'.mw-collapsible' | |||
); | |||
if ( | |||
mapCollapsible | |||
) { | |||
$( | |||
mapCollapsible | |||
).on( | |||
'afterExpand.mw-collapsible', | |||
function () { | |||
setTimeout( | |||
function () { | |||
map.invalidateSize(); | |||
}, | |||
0 | |||
); | |||
} | |||
); | |||
} | |||
/* | |||
* 初期状態ですでに表示されている | |||
* ケースの既存挙動も維持。 | |||
*/ | |||
setTimeout( | |||
function () { | |||
map.invalidateSize(); | |||
}, | |||
100 | |||
); | |||
console.log( | |||
'臨時設備位置地図ピン入力を初期化しました。' | |||
); | |||
}); | |||
}); | |||
/* === R13-01 Venue地域かな検索 START === */ | |||
/* | |||
* Form:Venue 地域欄: | |||
* 漢字検索は Page Forms 標準。 | |||
* ひらがな・カタカナ入力時は Areas.kana も検索する。 | |||
* | |||
* ResourceLoader互換: | |||
* ?? / ?. / async / await / arrow function は使用しない。 | |||
*/ | |||
( function () { | |||
'use strict'; | |||
var INSTALLED_ATTR = | |||
'data-r13-venue-kana-autocomplete'; | |||
function toHiragana( value ) { | |||
return String( value || '' ) | |||
.normalize( 'NFKC' ) | |||
.replace( | |||
/[ァ-ヶ]/g, | |||
function ( ch ) { | |||
return String.fromCharCode( | |||
ch.charCodeAt( 0 ) - 0x60 | |||
); | |||
} | |||
) | |||
.replace( /\s+/g, '' ) | |||
.trim(); | |||
} | |||
function isKana( value ) { | |||
return /^[ぁ-ゖー]+$/.test( value ); | |||
} | |||
function firstValue( obj, keys ) { | |||
var i; | |||
var key; | |||
var value; | |||
for ( i = 0; i < keys.length; i++ ) { | |||
key = keys[i]; | |||
value = obj[key]; | |||
if ( | |||
typeof value !== 'undefined' && | |||
value !== null && | |||
value !== '' | |||
) { | |||
return value; | |||
} | |||
} | |||
return ''; | |||
} | |||
function normalizeRow( raw ) { | |||
return { | |||
areaId: firstValue( | |||
raw, | |||
[ 'area_id', 'area id', 'areaId' ] | |||
), | |||
name: firstValue( | |||
raw, | |||
[ 'name', 'Name' ] | |||
), | |||
kana: firstValue( | |||
raw, | |||
[ 'kana', 'Kana' ] | |||
), | |||
areaType: firstValue( | |||
raw, | |||
[ 'area_type', 'area type', 'areaType' ] | |||
) | |||
}; | |||
} | |||
function areaTypeLabel( type ) { | |||
var labels = { | |||
prefecture: '都道府県', | |||
city: '市', | |||
special_ward: '特別区', | |||
ward: '行政区', | |||
town: '町', | |||
village: '村' | |||
}; | |||
return labels[type] || type || ''; | |||
} | |||
function findVenueAreaInput() { | |||
var inputs = | |||
document.querySelectorAll( | |||
'input[role="combobox"]' | |||
); | |||
var i; | |||
var input; | |||
var span; | |||
var hidden; | |||
for ( i = 0; i < inputs.length; i++ ) { | |||
input = inputs[i]; | |||
if ( | |||
input.getAttribute( | |||
'autocompletesettings' | |||
) !== 'Areas|name' | |||
) { | |||
continue; | |||
} | |||
span = | |||
input.closest( | |||
'.comboboxSpan' | |||
); | |||
if ( !span ) { | |||
continue; | |||
} | |||
hidden = | |||
span.querySelector( | |||
'input[type="hidden"]' + | |||
'[name="Venue[area_id]"]' | |||
); | |||
if ( hidden ) { | |||
return { | |||
input: input, | |||
hidden: hidden | |||
}; | |||
} | |||
} | |||
return null; | |||
} | |||
function install() { | |||
var pair = | |||
findVenueAreaInput(); | |||
if ( !pair ) { | |||
return false; | |||
} | |||
var input = | |||
pair.input; | |||
var hidden = | |||
pair.hidden; | |||
if ( | |||
input.getAttribute( | |||
INSTALLED_ATTR | |||
) === '1' | |||
) { | |||
return true; | |||
} | |||
input.setAttribute( | |||
INSTALLED_ATTR, | |||
'1' | |||
); | |||
var api = | |||
new mw.Api(); | |||
var box = | |||
document.createElement( | |||
'div' | |||
); | |||
box.className = | |||
'r13-venue-kana-results'; | |||
box.style.position = | |||
'absolute'; | |||
box.style.zIndex = | |||
'1000000'; | |||
box.style.background = | |||
'#fff'; | |||
box.style.border = | |||
'1px solid #a2a9b1'; | |||
box.style.borderRadius = | |||
'2px'; | |||
box.style.boxShadow = | |||
'0 2px 6px rgba(0,0,0,.2)'; | |||
box.style.maxHeight = | |||
'300px'; | |||
box.style.overflowY = | |||
'auto'; | |||
box.style.display = | |||
'none'; | |||
box.style.boxSizing = | |||
'border-box'; | |||
document.body.appendChild( | |||
box | |||
); | |||
var timer = null; | |||
var composing = false; | |||
var requestSeq = 0; | |||
function positionBox() { | |||
var rect = | |||
input.getBoundingClientRect(); | |||
box.style.left = | |||
( | |||
window.scrollX + | |||
rect.left | |||
) + 'px'; | |||
box.style.top = | |||
( | |||
window.scrollY + | |||
rect.bottom + | |||
2 | |||
) + 'px'; | |||
box.style.width = | |||
Math.max( | |||
rect.width, | |||
220 | |||
) + 'px'; | |||
} | |||
function clearBox() { | |||
while ( | |||
box.firstChild | |||
) { | |||
box.removeChild( | |||
box.firstChild | |||
); | |||
} | |||
} | |||
function hideBox() { | |||
box.style.display = | |||
'none'; | |||
clearBox(); | |||
} | |||
function selectArea( row ) { | |||
requestSeq++; | |||
/* | |||
* Page Forms側ではnameを保持し、 | |||
* フォーム送信時にarea_idへmapping。 | |||
*/ | |||
input.value = | |||
row.name; | |||
hidden.value = | |||
row.name; | |||
input.setAttribute( | |||
'data-value', | |||
row.name | |||
); | |||
input.setAttribute( | |||
'data-label', | |||
row.name | |||
); | |||
input.setAttribute( | |||
'data-string-type', | |||
'value' | |||
); | |||
input.title = | |||
row.name; | |||
hideBox(); | |||
input.dispatchEvent( | |||
new Event( | |||
'change', | |||
{ | |||
bubbles: true | |||
} | |||
) | |||
); | |||
/* | |||
* Page Formsのchange処理後も | |||
* nameを確定。 | |||
*/ | |||
input.value = | |||
row.name; | |||
hidden.value = | |||
row.name; | |||
} | |||
function render( | |||
rows, | |||
query | |||
) { | |||
var i; | |||
var row; | |||
var item; | |||
var name; | |||
var meta; | |||
clearBox(); | |||
if ( | |||
rows.length === 0 | |||
) { | |||
var empty = | |||
document.createElement( | |||
'div' | |||
); | |||
empty.textContent = | |||
'読み仮名に一致する地域がありません'; | |||
empty.style.padding = | |||
'8px 10px'; | |||
empty.style.color = | |||
'#54595d'; | |||
box.appendChild( | |||
empty | |||
); | |||
positionBox(); | |||
box.style.display = | |||
'block'; | |||
return; | |||
} | |||
rows.sort( | |||
function ( a, b ) { | |||
var ak = | |||
toHiragana( | |||
a.kana | |||
); | |||
var bk = | |||
toHiragana( | |||
b.kana | |||
); | |||
var ar = | |||
ak.indexOf( | |||
query | |||
) === 0 | |||
? 0 | |||
: 1; | |||
var br = | |||
bk.indexOf( | |||
query | |||
) === 0 | |||
? 0 | |||
: 1; | |||
if ( | |||
ar !== br | |||
) { | |||
return ar - br; | |||
} | |||
if ( | |||
ak.length !== | |||
bk.length | |||
) { | |||
return ( | |||
ak.length - | |||
bk.length | |||
); | |||
} | |||
return a.name.localeCompare( | |||
b.name, | |||
'ja' | |||
); | |||
} | |||
); | |||
rows = | |||
rows.slice( | |||
0, | |||
25 | |||
); | |||
for ( | |||
i = 0; | |||
i < rows.length; | |||
i++ | |||
) { | |||
row = | |||
rows[i]; | |||
item = | |||
document.createElement( | |||
'button' | |||
); | |||
item.type = | |||
'button'; | |||
item.style.display = | |||
'block'; | |||
item.style.width = | |||
'100%'; | |||
item.style.border = | |||
'0'; | |||
item.style.borderBottom = | |||
'1px solid #eaecf0'; | |||
item.style.background = | |||
'#fff'; | |||
item.style.padding = | |||
'7px 10px'; | |||
item.style.textAlign = | |||
'left'; | |||
item.style.cursor = | |||
'pointer'; | |||
item.style.font = | |||
'inherit'; | |||
name = | |||
document.createElement( | |||
'div' | |||
); | |||
name.textContent = | |||
row.name; | |||
name.style.fontWeight = | |||
'600'; | |||
name.style.color = | |||
'#202122'; | |||
meta = | |||
document.createElement( | |||
'div' | |||
); | |||
meta.textContent = | |||
row.kana + | |||
( | |||
row.areaType | |||
? ' ・ ' + | |||
areaTypeLabel( | |||
row.areaType | |||
) | |||
: '' | |||
); | |||
meta.style.marginTop = | |||
'2px'; | |||
meta.style.fontSize = | |||
'11px'; | |||
meta.style.color = | |||
'#72777d'; | |||
item.appendChild( | |||
name | |||
); | |||
item.appendChild( | |||
meta | |||
); | |||
( function ( | |||
button, | |||
area | |||
) { | |||
button.addEventListener( | |||
'mouseenter', | |||
function () { | |||
button.style.background = | |||
'#eaecf0'; | |||
} | |||
); | |||
button.addEventListener( | |||
'mouseleave', | |||
function () { | |||
button.style.background = | |||
'#fff'; | |||
} | |||
); | |||
button.addEventListener( | |||
'mousedown', | |||
function ( event ) { | |||
event.preventDefault(); | |||
} | |||
); | |||
button.addEventListener( | |||
'click', | |||
function () { | |||
selectArea( | |||
area | |||
); | |||
} | |||
); | |||
}( | |||
item, | |||
row | |||
) ); | |||
box.appendChild( | |||
item | |||
); | |||
} | |||
positionBox(); | |||
box.style.display = | |||
'block'; | |||
} | |||
function search() { | |||
var query = | |||
toHiragana( | |||
input.value | |||
); | |||
/* | |||
* かな2文字以上だけ追加検索。 | |||
* それ以外はPage Forms標準へ任せる。 | |||
*/ | |||
if ( | |||
query.length < 2 || | |||
!isKana( | |||
query | |||
) | |||
) { | |||
requestSeq++; | |||
hideBox(); | |||
return; | |||
} | |||
var seq = | |||
++requestSeq; | |||
var escaped = | |||
query.replace( | |||
/'/g, | |||
"''" | |||
); | |||
api.get( | |||
{ | |||
action: | |||
'cargoquery', | |||
format: | |||
'json', | |||
tables: | |||
'Areas', | |||
fields: | |||
'area_id,name,kana,area_type', | |||
where: | |||
"kana LIKE '%" + | |||
escaped + | |||
"%'", | |||
limit: | |||
200 | |||
} | |||
) | |||
.done( | |||
function ( | |||
result | |||
) { | |||
var rawRows = | |||
result.cargoquery || | |||
[]; | |||
var rows = []; | |||
var seen = {}; | |||
var i; | |||
var raw; | |||
var row; | |||
var key; | |||
if ( | |||
seq !== | |||
requestSeq | |||
) { | |||
return; | |||
} | |||
for ( | |||
i = 0; | |||
i < | |||
rawRows.length; | |||
i++ | |||
) { | |||
raw = | |||
rawRows[i].title || | |||
rawRows[i]; | |||
row = | |||
normalizeRow( | |||
raw | |||
); | |||
if ( | |||
!row.areaId || | |||
!row.name || | |||
!row.kana | |||
) { | |||
continue; | |||
} | |||
key = | |||
String( | |||
row.areaId | |||
); | |||
if ( | |||
seen[key] | |||
) { | |||
continue; | |||
} | |||
seen[key] = | |||
true; | |||
rows.push( | |||
row | |||
); | |||
} | |||
render( | |||
rows, | |||
query | |||
); | |||
} | |||
) | |||
.fail( | |||
function ( | |||
code, | |||
details | |||
) { | |||
if ( | |||
seq !== | |||
requestSeq | |||
) { | |||
return; | |||
} | |||
console.error( | |||
'[R13-01 VenueKana]', | |||
code, | |||
details | |||
); | |||
hideBox(); | |||
} | |||
); | |||
} | |||
function schedule() { | |||
if ( | |||
composing | |||
) { | |||
return; | |||
} | |||
clearTimeout( | |||
timer | |||
); | |||
timer = | |||
setTimeout( | |||
search, | |||
180 | |||
); | |||
} | |||
input.addEventListener( | |||
'compositionstart', | |||
function () { | |||
composing = | |||
true; | |||
} | |||
); | |||
input.addEventListener( | |||
'compositionend', | |||
function () { | |||
composing = | |||
false; | |||
schedule(); | |||
} | |||
); | |||
input.addEventListener( | |||
'input', | |||
schedule | |||
); | |||
document.addEventListener( | |||
'mousedown', | |||
function ( | |||
event | |||
) { | |||
if ( | |||
event.target !== | |||
input && | |||
!box.contains( | |||
event.target | |||
) | |||
) { | |||
hideBox(); | |||
} | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
'resize', | |||
function () { | |||
if ( | |||
box.style.display !== | |||
'none' | |||
) { | |||
positionBox(); | |||
} | |||
} | |||
); | |||
window.addEventListener( | |||
'scroll', | |||
function () { | |||
if ( | |||
box.style.display !== | |||
'none' | |||
) { | |||
positionBox(); | |||
} | |||
}, | |||
true | |||
); | |||
return true; | |||
} | |||
function start() { | |||
var attempts = | |||
0; | |||
var installTimer = | |||
window.setInterval( | |||
function () { | |||
attempts++; | |||
if ( | |||
install() || | |||
attempts >= 40 | |||
) { | |||
window.clearInterval( | |||
installTimer | |||
); | |||
} | |||
}, | |||
250 | |||
); | |||
} | |||
function ready() { | |||
if ( | |||
document.readyState === | |||
'loading' | |||
) { | |||
document.addEventListener( | |||
'DOMContentLoaded', | |||
start, | |||
{ | |||
once: true | |||
} | |||
); | |||
} else { | |||
start(); | |||
} | |||
} | |||
mw.loader.using( | |||
'mediawiki.api', | |||
ready, | |||
function ( error ) { | |||
console.error( | |||
'[R13-01 VenueKana load]', | |||
error | |||
); | |||
} | |||
); | |||
}() ); | |||
/* === R13-01 Venue地域かな検索 END === */ | |||
/* === R14-02 Festival combobox hidden change bridge START === */ | |||
$(function () { | |||
function bindFestivalCombobox(fieldName) { | |||
var hidden = | |||
document.querySelector( | |||
'input[type="hidden"][name="' + | |||
fieldName + | |||
'"]' | |||
); | |||
if (!hidden) { | |||
return false; | |||
} | |||
if ( | |||
hidden.getAttribute( | |||
'data-r14-festival-combo-bridge' | |||
) === '1' | |||
) { | |||
return true; | |||
} | |||
var span = | |||
$(hidden).closest( | |||
'.comboboxSpan' | |||
)[0]; | |||
if (!span) { | |||
return false; | |||
} | |||
var visible = | |||
span.querySelector( | |||
'input:not([type="hidden"])' | |||
); | |||
if (!visible) { | |||
return false; | |||
} | |||
hidden.setAttribute( | |||
'data-r14-festival-combo-bridge', | |||
'1' | |||
); | |||
var lastValue = | |||
String(hidden.value || ''); | |||
var timer = null; | |||
function dispatchHiddenChange() { | |||
var event = | |||
document.createEvent( | |||
'HTMLEvents' | |||
); | |||
event.initEvent( | |||
'change', | |||
true, | |||
false | |||
); | |||
hidden.dispatchEvent( | |||
event | |||
); | |||
} | |||
function checkHiddenValue() { | |||
if (timer !== null) { | |||
window.clearTimeout( | |||
timer | |||
); | |||
} | |||
timer = | |||
window.setTimeout( | |||
function () { | |||
timer = null; | |||
var nextValue = | |||
String( | |||
hidden.value || | |||
'' | |||
); | |||
if ( | |||
nextValue === | |||
lastValue | |||
) { | |||
return; | |||
} | |||
lastValue = | |||
nextValue; | |||
dispatchHiddenChange(); | |||
}, | |||
0 | |||
); | |||
} | |||
visible.addEventListener( | |||
'input', | |||
checkHiddenValue | |||
); | |||
visible.addEventListener( | |||
'change', | |||
checkHiddenValue | |||
); | |||
visible.addEventListener( | |||
'blur', | |||
checkHiddenValue | |||
); | |||
span.addEventListener( | |||
'mouseup', | |||
checkHiddenValue | |||
); | |||
span.addEventListener( | |||
'keyup', | |||
checkHiddenValue | |||
); | |||
return true; | |||
} | |||
function retryBindFestivalCombobox( | |||
fieldName | |||
) { | |||
var rawCombobox = | |||
document.querySelector( | |||
'select.pfComboBox[name="' + | |||
fieldName + | |||
'"]' | |||
); | |||
var hidden = | |||
document.querySelector( | |||
'input[type="hidden"][name="' + | |||
fieldName + | |||
'"]' | |||
); | |||
/* | |||
* Do not start retry timers on unrelated pages | |||
* or on fields that are still ordinary dropdowns. | |||
*/ | |||
if (!rawCombobox && !hidden) { | |||
return; | |||
} | |||
var attempts = 0; | |||
var maxAttempts = 50; | |||
function tryBind() { | |||
if ( | |||
bindFestivalCombobox( | |||
fieldName | |||
) | |||
) { | |||
return; | |||
} | |||
attempts += 1; | |||
if (attempts >= maxAttempts) { | |||
console.warn( | |||
'[R14-02] Festival combobox ' + | |||
'bridge initialization timed out.', | |||
fieldName | |||
); | |||
return; | |||
} | |||
window.setTimeout( | |||
tryBind, | |||
100 | |||
); | |||
} | |||
tryBind(); | |||
} | |||
function setupFestivalComboboxBridges() { | |||
retryBindFestivalCombobox( | |||
'FestivalStallPlacement[festival_id]' | |||
); | |||
retryBindFestivalCombobox( | |||
'FestivalTemporaryFacility[festival_id]' | |||
); | |||
} | |||
setupFestivalComboboxBridges(); | |||
mw.hook( | |||
'pf.formSetupAfter' | |||
).add( | |||
setupFestivalComboboxBridges | |||
); | |||
}); | |||
/* === R14-02 Festival combobox hidden change bridge END === */ | |||
/* === R16 Entity duplicate candidate checker START === */ | |||
/* | |||
* Festival / Venue / Stall の名称入力時に、 | |||
* Cargoの既存レコードから重複候補を表示する。 | |||
* | |||
* 強い候補がある場合は、候補確認チェックを行うまで | |||
* wpSaveだけを停止する。プレビューと差分確認は利用可能。 | |||
*/ | |||
mw.loader.using([ | |||
'mediawiki.api', | |||
'mediawiki.util' | |||
]).then(function () { | |||
'use strict'; | |||
var INSTALLED_ATTR = | |||
'data-r16-entity-duplicate'; | |||
var configs = [ | |||
{ | |||
template: 'Stall', | |||
selector: '[name="Stall[name]"]', | |||
table: 'Stalls', | |||
fields: | |||
'_pageName=page_name,' + | |||
'name=name,' + | |||
'category=detail', | |||
entityLabel: '屋台', | |||
detailLabel: 'カテゴリ', | |||
hasKana: false | |||
}, | |||
{ | |||
template: 'Venue', | |||
selector: '[name="Venue[name]"]', | |||
table: 'Venues', | |||
fields: | |||
'_pageName=page_name,' + | |||
'name=name,' + | |||
'kana=kana,' + | |||
'address=detail', | |||
entityLabel: '会場', | |||
detailLabel: '住所', | |||
hasKana: true | |||
}, | |||
{ | |||
template: 'Festival', | |||
selector: '[name="Festival[name]"]', | |||
table: 'Festivals', | |||
fields: | |||
'_pageName=page_name,' + | |||
'name=name,' + | |||
'kana=kana,' + | |||
'organizer=detail', | |||
entityLabel: '祭り', | |||
detailLabel: '主催', | |||
hasKana: true | |||
} | |||
]; | |||
function normalizeSearch(value) { | |||
return String(value || '') | |||
.normalize('NFKC') | |||
.replace( | |||
/[ァ-ヶ]/g, | |||
function (character) { | |||
return String.fromCharCode( | |||
character.charCodeAt(0) - | |||
0x60 | |||
); | |||
} | |||
) | |||
.toLowerCase() | |||
.replace( | |||
/[\s\u3000・・//()()[\]【】「」『』\-‐‑‒–—―]+/g, | |||
'' | |||
); | |||
} | |||
function normalizePageName(value) { | |||
return String(value || '') | |||
.normalize('NFKC') | |||
.replace(/_/g, ' ') | |||
.replace(/\s+/g, ' ') | |||
.trim(); | |||
} | |||
function levenshtein(left, right) { | |||
var previous = []; | |||
var current; | |||
var i; | |||
var j; | |||
var cost; | |||
for (j = 0; j <= right.length; j++) { | |||
previous[j] = j; | |||
} | |||
for (i = 1; i <= left.length; i++) { | |||
current = [i]; | |||
for (j = 1; j <= right.length; j++) { | |||
cost = ( | |||
left.charAt(i - 1) === | |||
right.charAt(j - 1) | |||
) ? 0 : 1; | |||
current[j] = Math.min( | |||
current[j - 1] + 1, | |||
previous[j] + 1, | |||
previous[j - 1] + cost | |||
); | |||
} | |||
previous = current; | |||
} | |||
return previous[right.length]; | |||
} | |||
function bigrams(value) { | |||
var result = []; | |||
var i; | |||
if (value.length < 2) { | |||
return value ? [value] : []; | |||
} | |||
for (i = 0; i < value.length - 1; i++) { | |||
result.push( | |||
value.slice(i, i + 2) | |||
); | |||
} | |||
return result; | |||
} | |||
function dice(left, right) { | |||
var leftParts = bigrams(left); | |||
var rightParts = bigrams(right); | |||
var used = {}; | |||
var common = 0; | |||
var i; | |||
var j; | |||
if ( | |||
leftParts.length === 0 || | |||
rightParts.length === 0 | |||
) { | |||
return 0; | |||
} | |||
for (i = 0; i < leftParts.length; i++) { | |||
for ( | |||
j = 0; | |||
j < rightParts.length; | |||
j++ | |||
) { | |||
if ( | |||
!used[j] && | |||
leftParts[i] === rightParts[j] | |||
) { | |||
used[j] = true; | |||
common++; | |||
break; | |||
} | |||
} | |||
} | |||
return ( | |||
2 * common / | |||
( | |||
leftParts.length + | |||
rightParts.length | |||
) | |||
); | |||
} | |||
function compare(query, candidate) { | |||
var ratio; | |||
var distance; | |||
var editScore; | |||
var diceScore; | |||
if (!query || !candidate) { | |||
return { | |||
score: 0, | |||
reason: '' | |||
}; | |||
} | |||
if (query === candidate) { | |||
return { | |||
score: 1, | |||
reason: '完全一致' | |||
}; | |||
} | |||
/* | |||
* 入力より短い一般語は低く評価する。 | |||
* 例:「やきそば」に対する「そば」。 | |||
*/ | |||
if (query.indexOf(candidate) !== -1) { | |||
ratio = | |||
candidate.length / query.length; | |||
return { | |||
score: | |||
0.45 + 0.25 * ratio, | |||
reason: '短い名称を含む' | |||
}; | |||
} | |||
if (candidate.indexOf(query) !== -1) { | |||
ratio = | |||
query.length / candidate.length; | |||
return { | |||
score: | |||
0.65 + 0.25 * ratio, | |||
reason: '名称の一部が一致' | |||
}; | |||
} | |||
distance = levenshtein( | |||
query, | |||
candidate | |||
); | |||
editScore = 1 - ( | |||
distance / | |||
Math.max( | |||
query.length, | |||
candidate.length | |||
) | |||
); | |||
diceScore = dice( | |||
query, | |||
candidate | |||
); | |||
if (editScore >= diceScore) { | |||
return { | |||
score: Math.max( | |||
0, | |||
editScore | |||
), | |||
reason: | |||
'表記が近い(差' + | |||
distance + | |||
'文字)' | |||
}; | |||
} | |||
return { | |||
score: diceScore, | |||
reason: '共通する文字列あり' | |||
}; | |||
} | |||
function findConfig(root) { | |||
var i; | |||
var input; | |||
for (i = 0; i < configs.length; i++) { | |||
input = root.querySelector( | |||
configs[i].selector | |||
); | |||
if (input) { | |||
return { | |||
config: configs[i], | |||
input: input | |||
}; | |||
} | |||
} | |||
return null; | |||
} | |||
function currentTarget(config) { | |||
var pageName = String( | |||
mw.config.get('wgPageName') || '' | |||
).replace(/_/g, ' '); | |||
var parts = pageName.split('/'); | |||
var i; | |||
for (i = 0; i < parts.length; i++) { | |||
if (parts[i] === config.template) { | |||
return parts.slice(i + 1).join('/'); | |||
} | |||
} | |||
return ''; | |||
} | |||
function createTextElement(tag, className, text) { | |||
var element = | |||
document.createElement(tag); | |||
if (className) { | |||
element.className = className; | |||
} | |||
element.textContent = text; | |||
return element; | |||
} | |||
function setupDuplicateChecker() { | |||
var root = | |||
document.getElementById('pfForm'); | |||
if (!root) { | |||
return false; | |||
} | |||
if ( | |||
root.getAttribute( | |||
INSTALLED_ATTR | |||
) === '1' | |||
) { | |||
return true; | |||
} | |||
var found = findConfig(root); | |||
if (!found) { | |||
return false; | |||
} | |||
root.setAttribute( | |||
INSTALLED_ATTR, | |||
'1' | |||
); | |||
var config = found.config; | |||
var input = found.input; | |||
var target = normalizePageName( | |||
currentTarget(config) | |||
); | |||
var panel = | |||
document.createElement('div'); | |||
panel.id = | |||
'r16-entity-duplicate-panel'; | |||
panel.className = | |||
'stall-duplicate-warning'; | |||
panel.setAttribute( | |||
'role', | |||
'status' | |||
); | |||
var inputCell = | |||
input.closest('td') || | |||
input.parentNode; | |||
inputCell.appendChild(panel); | |||
var saveButton = | |||
root.querySelector( | |||
'[name="wpSave"]' | |||
); | |||
var saveWarning = | |||
document.createElement('div'); | |||
saveWarning.id = | |||
'r16-entity-duplicate-save-warning'; | |||
saveWarning.className = | |||
'stall-duplicate-warning'; | |||
saveWarning.setAttribute( | |||
'role', | |||
'alert' | |||
); | |||
saveWarning.hidden = true; | |||
var saveMessage = | |||
createTextElement( | |||
'strong', | |||
'stall-duplicate-warning-title', | |||
'' | |||
); | |||
var confirmationLabel = | |||
document.createElement('label'); | |||
var confirmation = | |||
document.createElement('input'); | |||
confirmation.type = 'checkbox'; | |||
confirmation.value = '1'; | |||
confirmationLabel.appendChild( | |||
confirmation | |||
); | |||
confirmationLabel.appendChild( | |||
document.createTextNode( | |||
' 候補を確認し、別の' + | |||
config.entityLabel + | |||
'として登録します。' | |||
) | |||
); | |||
saveWarning.appendChild(saveMessage); | |||
saveWarning.appendChild( | |||
confirmationLabel | |||
); | |||
if (saveButton) { | |||
var saveAnchor = | |||
saveButton.closest( | |||
'.oo-ui-widget' | |||
) || saveButton; | |||
if (saveAnchor.parentNode) { | |||
saveAnchor.parentNode.insertBefore( | |||
saveWarning, | |||
saveAnchor | |||
); | |||
} else { | |||
root.appendChild(saveWarning); | |||
} | |||
} else { | |||
root.appendChild(saveWarning); | |||
} | |||
var api = new mw.Api(); | |||
var rows = []; | |||
var timer = null; | |||
var composing = false; | |||
var ready = false; | |||
var failed = false; | |||
var strongCandidates = []; | |||
var lastQuery = ''; | |||
function setStrongTone(strong) { | |||
panel.style.borderColor = strong | |||
? '#b32424' | |||
: ''; | |||
panel.style.borderLeftColor = strong | |||
? '#b32424' | |||
: ''; | |||
panel.style.background = strong | |||
? '#fee7e6' | |||
: ''; | |||
} | |||
function showSimple(text) { | |||
panel.replaceChildren( | |||
createTextElement( | |||
'span', | |||
'', | |||
text | |||
) | |||
); | |||
setStrongTone(false); | |||
strongCandidates = []; | |||
saveWarning.hidden = true; | |||
} | |||
function render() { | |||
var query = | |||
normalizeSearch(input.value); | |||
var ranked = []; | |||
var i; | |||
var row; | |||
var nameMatch; | |||
var kanaMatch; | |||
var selectedMatch; | |||
var heading; | |||
var description; | |||
var list; | |||
if (query !== lastQuery) { | |||
confirmation.checked = false; | |||
lastQuery = query; | |||
} | |||
if (query.length < 2) { | |||
showSimple( | |||
'2文字以上入力すると、' + | |||
'登録済みの' + | |||
config.entityLabel + | |||
'候補を表示します。' | |||
); | |||
return; | |||
} | |||
if (!ready) { | |||
if (failed) { | |||
showSimple( | |||
'既存候補を取得できませんでした。' + | |||
'保存前に一覧ページもご確認ください。' | |||
); | |||
} else { | |||
showSimple( | |||
'登録済み候補を確認しています…' | |||
); | |||
} | |||
return; | |||
} | |||
for (i = 0; i < rows.length; i++) { | |||
row = rows[i]; | |||
if ( | |||
target && | |||
normalizePageName( | |||
row.pageName | |||
) === target | |||
) { | |||
continue; | |||
} | |||
nameMatch = compare( | |||
query, | |||
normalizeSearch(row.name) | |||
); | |||
kanaMatch = config.hasKana | |||
? compare( | |||
query, | |||
normalizeSearch(row.kana) | |||
) | |||
: { | |||
score: 0, | |||
reason: '' | |||
}; | |||
if ( | |||
kanaMatch.score > | |||
nameMatch.score | |||
) { | |||
selectedMatch = { | |||
score: kanaMatch.score, | |||
reason: | |||
'よみ:' + | |||
kanaMatch.reason | |||
}; | |||
} else { | |||
selectedMatch = { | |||
score: nameMatch.score, | |||
reason: | |||
'名称:' + | |||
nameMatch.reason | |||
}; | |||
} | |||
if (selectedMatch.score < 0.35) { | |||
continue; | |||
} | |||
ranked.push({ | |||
pageName: row.pageName, | |||
name: row.name, | |||
kana: row.kana, | |||
detail: row.detail, | |||
score: selectedMatch.score, | |||
reason: selectedMatch.reason | |||
}); | |||
} | |||
ranked.sort(function (left, right) { | |||
if (right.score !== left.score) { | |||
return right.score - left.score; | |||
} | |||
return left.name.localeCompare( | |||
right.name, | |||
'ja' | |||
); | |||
}); | |||
ranked = ranked.slice(0, 5); | |||
strongCandidates = ranked.filter( | |||
function (candidate) { | |||
return candidate.score >= 0.72; | |||
} | |||
); | |||
panel.replaceChildren(); | |||
if (ranked.length === 0) { | |||
showSimple( | |||
'似ている登録済み候補は' + | |||
'見つかりませんでした。' | |||
); | |||
return; | |||
} | |||
heading = createTextElement( | |||
'strong', | |||
'stall-duplicate-warning-title', | |||
strongCandidates.length | |||
? '重複の可能性が高い候補があります' | |||
: '似ている登録済み候補' | |||
); | |||
description = createTextElement( | |||
'p', | |||
'stall-duplicate-warning-description', | |||
'既存ページを確認し、同じ対象なら' + | |||
'新規登録せず既存ページを編集してください。' | |||
); | |||
list = document.createElement('ul'); | |||
list.className = | |||
'stall-duplicate-warning-list'; | |||
ranked.forEach(function (candidate) { | |||
var item = | |||
document.createElement('li'); | |||
var link = | |||
document.createElement('a'); | |||
var details = []; | |||
link.href = mw.util.getUrl( | |||
candidate.pageName | |||
); | |||
link.target = '_blank'; | |||
link.rel = 'noopener'; | |||
link.textContent = candidate.name; | |||
if (candidate.kana) { | |||
details.push( | |||
'よみ:' + candidate.kana | |||
); | |||
} | |||
if (candidate.detail) { | |||
details.push( | |||
config.detailLabel + | |||
':' + | |||
candidate.detail | |||
); | |||
} | |||
details.push(candidate.reason); | |||
details.push( | |||
'一致度' + | |||
Math.round( | |||
candidate.score * 100 | |||
) + | |||
'%' | |||
); | |||
item.appendChild(link); | |||
item.appendChild( | |||
document.createTextNode( | |||
' — ' + | |||
details.join('/') | |||
) | |||
); | |||
list.appendChild(item); | |||
}); | |||
panel.appendChild(heading); | |||
panel.appendChild(description); | |||
panel.appendChild(list); | |||
setStrongTone( | |||
strongCandidates.length > 0 | |||
); | |||
if (strongCandidates.length > 0) { | |||
saveMessage.textContent = | |||
'重複の可能性が高い候補が' + | |||
strongCandidates.length + | |||
'件あります。'; | |||
saveWarning.hidden = false; | |||
} else { | |||
saveWarning.hidden = true; | |||
} | |||
console.log( | |||
'R16_ENTITY_DUPLICATE_RESULT', | |||
{ | |||
entity: config.template, | |||
input: input.value, | |||
target: target, | |||
candidates: ranked, | |||
strongCandidateCount: | |||
strongCandidates.length | |||
} | |||
); | |||
} | |||
function scheduleRender() { | |||
if (composing) { | |||
return; | |||
} | |||
window.clearTimeout(timer); | |||
timer = window.setTimeout( | |||
render, | |||
250 | |||
); | |||
} | |||
function shouldBlockSave() { | |||
return ( | |||
normalizeSearch(input.value).length >= 2 && | |||
!failed && | |||
( | |||
!ready || | |||
( | |||
strongCandidates.length > 0 && | |||
!confirmation.checked | |||
) | |||
) | |||
); | |||
} | |||
function blockSave(event) { | |||
if (!shouldBlockSave()) { | |||
return; | |||
} | |||
event.preventDefault(); | |||
event.stopImmediatePropagation(); | |||
if (!ready) { | |||
saveMessage.textContent = | |||
'既存候補の確認が完了するまで' + | |||
'お待ちください。'; | |||
} else { | |||
saveMessage.textContent = | |||
'既存候補を確認し、別データとして' + | |||
'登録する場合はチェックしてください。'; | |||
} | |||
saveWarning.hidden = false; | |||
saveWarning.scrollIntoView({ | |||
behavior: 'smooth', | |||
block: 'center' | |||
}); | |||
if (ready) { | |||
confirmation.focus(); | |||
} | |||
} | |||
input.addEventListener( | |||
'compositionstart', | |||
function () { | |||
composing = true; | |||
} | |||
); | |||
input.addEventListener( | |||
'compositionend', | |||
function () { | |||
composing = false; | |||
scheduleRender(); | |||
} | |||
); | |||
input.addEventListener( | |||
'input', | |||
scheduleRender | |||
); | |||
if (saveButton) { | |||
saveButton.addEventListener( | |||
'click', | |||
blockSave, | |||
true | |||
); | |||
} | |||
var formElement = | |||
input.closest('form'); | |||
if (formElement) { | |||
formElement.addEventListener( | |||
'submit', | |||
function (event) { | |||
var submitter = | |||
event.submitter; | |||
if ( | |||
submitter && | |||
submitter.name !== 'wpSave' | |||
) { | |||
return; | |||
} | |||
blockSave(event); | |||
}, | |||
true | |||
); | |||
} | |||
showSimple( | |||
'登録済み候補を読み込んでいます…' | |||
); | |||
api.get({ | |||
action: 'cargoquery', | |||
format: 'json', | |||
tables: config.table, | |||
fields: config.fields, | |||
limit: 500 | |||
}).then(function (data) { | |||
rows = ( | |||
data.cargoquery || [] | |||
).map(function (item) { | |||
var value = item.title || {}; | |||
return { | |||
pageName: | |||
value.page_name || '', | |||
name: | |||
value.name || '', | |||
kana: | |||
value.kana || '', | |||
detail: | |||
value.detail || '' | |||
}; | |||
}).filter(function (row) { | |||
return ( | |||
row.pageName && | |||
row.name | |||
); | |||
}); | |||
ready = true; | |||
failed = false; | |||
render(); | |||
console.log( | |||
'R16_ENTITY_DUPLICATE_READY', | |||
{ | |||
entity: config.template, | |||
loadedRows: rows.length | |||
} | |||
); | |||
}).catch(function (error) { | |||
ready = false; | |||
failed = true; | |||
render(); | |||
console.error( | |||
'重複候補の取得に失敗しました。', | |||
error | |||
); | |||
}); | |||
return true; | |||
} | |||
if (document.readyState === 'loading') { | |||
document.addEventListener( | |||
'DOMContentLoaded', | |||
setupDuplicateChecker | |||
); | |||
} else { | |||
setupDuplicateChecker(); | |||
} | |||
mw.hook('pf.formSetupAfter').add( | |||
setupDuplicateChecker | |||
); | |||
mw.hook('wikipage.content').add( | |||
setupDuplicateChecker | |||
); | |||
}); | |||
/* === R16 Entity duplicate candidate checker END === */ | |||
/* === R16 Venue/Festival new-form name autofill START === */ | |||
/* | |||
* PageFormsで指定した新規ページ名を、 | |||
* 空の会場名・祭り名へ初期値として反映する。 | |||
* | |||
* 既に値がある場合は上書きしない。 | |||
* 祭りページ名に全角の区切り「|」がある場合は、 | |||
* 区切りより前だけを正式名称候補として使用する。 | |||
*/ | |||
(function () { | |||
'use strict'; | |||
var installAttribute = | |||
'data-r16-name-prefill'; | |||
var configs = [ | |||
{ | |||
entity: 'Venue', | |||
selector: '[name="Venue[name]"]', | |||
transform: function (targetName) { | |||
return targetName; | |||
} | |||
}, | |||
{ | |||
entity: 'Festival', | |||
selector: '[name="Festival[name]"]', | |||
transform: function (targetName) { | |||
return targetName | |||
.split('|')[0] | |||
.trim(); | |||
} | |||
} | |||
]; | |||
function dispatchValueEvents(input) { | |||
input.dispatchEvent( | |||
new Event( | |||
'input', | |||
{ | |||
bubbles: true | |||
} | |||
) | |||
); | |||
input.dispatchEvent( | |||
new Event( | |||
'change', | |||
{ | |||
bubbles: true | |||
} | |||
) | |||
); | |||
} | |||
function setupNamePrefill() { | |||
var canonicalSpecial = | |||
mw.config.get( | |||
'wgCanonicalSpecialPageName' | |||
); | |||
var targetName = | |||
String( | |||
mw.config.get( | |||
'wgPageFormsTargetName' | |||
) || '' | |||
).trim(); | |||
var form = | |||
document.getElementById('pfForm'); | |||
var i; | |||
var config; | |||
var input; | |||
var value; | |||
if ( | |||
canonicalSpecial !== 'FormEdit' || | |||
targetName === '' || | |||
targetName === 'Dummy title' || | |||
!form | |||
) { | |||
return false; | |||
} | |||
if ( | |||
form.getAttribute( | |||
installAttribute | |||
) === '1' | |||
) { | |||
return true; | |||
} | |||
for (i = 0; i < configs.length; i++) { | |||
config = configs[i]; | |||
input = form.querySelector( | |||
config.selector | |||
); | |||
if (!input) { | |||
continue; | |||
} | |||
form.setAttribute( | |||
installAttribute, | |||
'1' | |||
); | |||
if ( | |||
String(input.value || '') | |||
.trim() !== '' | |||
) { | |||
return true; | |||
} | |||
value = | |||
config.transform(targetName); | |||
if (value === '') { | |||
return true; | |||
} | |||
input.value = value; | |||
dispatchValueEvents(input); | |||
console.info( | |||
'R16_NAME_PREFILL', | |||
{ | |||
entity: config.entity, | |||
targetName: targetName, | |||
value: value | |||
} | |||
); | |||
return true; | |||
} | |||
return false; | |||
} | |||
function installNamePrefill() { | |||
if (setupNamePrefill()) { | |||
return; | |||
} | |||
window.setTimeout( | |||
setupNamePrefill, | |||
0 | |||
); | |||
} | |||
if ( | |||
document.readyState === 'loading' | |||
) { | |||
document.addEventListener( | |||
'DOMContentLoaded', | |||
installNamePrefill, | |||
{ | |||
once: true | |||
} | |||
); | |||
} else { | |||
installNamePrefill(); | |||
} | |||
if (mw.hook) { | |||
mw.hook( | |||
'pf.formSetupAfter' | |||
).add( | |||
installNamePrefill | |||
); | |||
mw.hook( | |||
'wikipage.content' | |||
).add( | |||
installNamePrefill | |||
); | |||
} | |||
}()); | |||
/* === R16 Venue/Festival new-form name autofill END === */ | |||
2026年9月12日 (土) 22:09時点における最新版
/* ========================================
* 屋台比較
* placement_id 正式版
*
* 最大4出店
*
* localStorage:
* matsuriWikiComparePlacements
*
* 1 placement =
* 1 festival + 1 year + 1 venue + 1 stall
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MAX_COMPARE = 4;
const api =
new mw.Api();
/*
* ページ表示中だけ使うキャッシュ
*/
const placementInfoCache = {};
/* =====================================
* localStorage
* ===================================== */
function getComparePlacements() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse( raw );
if ( !Array.isArray( ids ) ) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
function saveComparePlacements( ids ) {
mw.storage.set(
STORAGE_KEY,
JSON.stringify( ids )
);
}
/* =====================================
* Cargo共通処理
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return item.title || item;
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
)
];
}
function mapBy( rows, key ) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] === undefined ||
row[ key ] === null
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* Placement情報取得
* ===================================== */
function fetchPlacementInfo( ids ) {
if ( ids.length === 0 ) {
return Promise.resolve( {} );
}
const result = {};
const missingIds = [];
ids.forEach(
function ( id ) {
if (
placementInfoCache[ id ]
) {
result[ id ] =
placementInfoCache[ id ];
} else {
missingIds.push( id );
}
}
);
if ( missingIds.length === 0 ) {
return Promise.resolve(
result
);
}
return cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'stall_id=stall_id,' +
'festival_id=festival_id,' +
'venue_id=venue_id,' +
'year=year,' +
'location_note=location_note',
'placement_id IN (' +
makeInClause(
missingIds
) +
')',
100
).then(
function ( placements ) {
const stallIds =
uniqueIds(
placements.map(
function ( row ) {
return row.stall_id;
}
)
);
const festivalIds =
uniqueIds(
placements.map(
function ( row ) {
return row.festival_id;
}
)
);
const venueIds =
uniqueIds(
placements.map(
function ( row ) {
return row.venue_id;
}
)
);
return Promise.all( [
stallIds.length
? cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name,' +
'_pageName=page_name',
'stall_id IN (' +
makeInClause(
stallIds
) +
')',
100
)
: Promise.resolve( [] ),
festivalIds.length
? cargoQuery(
'Festivals',
'festival_id=festival_id,' +
'name=festival_name,' +
'_pageName=page_name',
'festival_id IN (' +
makeInClause(
festivalIds
) +
')',
100
)
: Promise.resolve( [] ),
venueIds.length
? cargoQuery(
'Venues',
'venue_id=venue_id,' +
'name=venue_name,' +
'_pageName=page_name',
'venue_id IN (' +
makeInClause(
venueIds
) +
')',
100
)
: Promise.resolve( [] )
] ).then(
function ( related ) {
return {
placements:
placements,
stalls:
related[ 0 ],
festivals:
related[ 1 ],
venues:
related[ 2 ]
};
}
);
}
).then(
function ( data ) {
const stallMap =
mapBy(
data.stalls,
'stall_id'
);
const festivalMap =
mapBy(
data.festivals,
'festival_id'
);
const venueMap =
mapBy(
data.venues,
'venue_id'
);
data.placements.forEach(
function ( placement ) {
const placementId =
String(
placement.placement_id
);
const info = {
placement:
placement,
stall:
stallMap[
String(
placement.stall_id
)
] || null,
festival:
festivalMap[
String(
placement.festival_id
)
] || null,
venue:
venueMap[
String(
placement.venue_id
)
] || null
};
placementInfoCache[
placementId
] = info;
result[
placementId
] = info;
}
);
return result;
}
);
}
/* =====================================
* メッセージ
* ===================================== */
function showMessage(
control,
message,
isError
) {
const element =
control.querySelector(
'.stall-compare-message'
);
if ( !element ) {
return;
}
element.textContent =
message;
element.classList.toggle(
'stall-compare-message-error',
Boolean( isError )
);
}
/* =====================================
* 比較ボタン生成
*
* 通常ページ
* stall-compare-placeholder-1
*
* 地図popup
* stall-map-compare-placeholder-1
*
* の両方に対応
* ===================================== */
function createCompareButtons() {
document
.querySelectorAll(
'.stall-compare-placeholder, ' +
'.stall-map-compare-placeholder'
)
.forEach(
function ( placeholder ) {
/*
* すでにbutton生成済み
*/
if (
placeholder.dataset.initialized ===
'1'
) {
return;
}
let placementId = '';
/*
* 通常ページ
*/
const normalPrefix =
'stall-compare-placeholder-';
/*
* 地図popup
*/
const mapPrefix =
'stall-map-compare-placeholder-';
if (
placeholder.id.startsWith(
normalPrefix
)
) {
placementId =
placeholder.id.substring(
normalPrefix.length
);
} else if (
placeholder.id.startsWith(
mapPrefix
)
) {
placementId =
placeholder.id.substring(
mapPrefix.length
);
} else {
return;
}
/*
* placement_idチェック
*/
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
/*
* 本物のbuttonを
* JavaScript側で作る
*/
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'stall-compare-button';
button.dataset.placementId =
placementId;
button.setAttribute(
'aria-pressed',
'false'
);
button.textContent =
'比較に追加';
placeholder.appendChild(
button
);
/*
* 二重生成防止
*/
placeholder.dataset.initialized =
'1';
}
);
}
/* =====================================
* ボタン状態更新
* ===================================== */
function updateCompareButtons() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'.stall-compare-button'
)
.forEach(
function ( button ) {
const placementId =
String(
button.dataset
.placementId || ''
);
const selected =
ids.includes(
placementId
);
if ( selected ) {
button.textContent =
'比較から外す';
button.classList.add(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'true'
);
} else {
button.textContent =
'比較に追加';
button.classList.remove(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 固定比較トレイ生成
* ===================================== */
function createCompareTray() {
if (
document.getElementById(
'stall-compare-tray'
)
) {
return;
}
const tray =
document.createElement(
'div'
);
tray.id =
'stall-compare-tray';
tray.className =
'stall-compare-tray';
tray.setAttribute(
'aria-live',
'polite'
);
const header =
document.createElement(
'div'
);
header.className =
'stall-compare-tray-header';
const title =
document.createElement(
'strong'
);
title.className =
'stall-compare-tray-title';
title.textContent =
'比較候補';
const count =
document.createElement(
'span'
);
count.className =
'stall-compare-tray-count';
header.appendChild(
title
);
header.appendChild(
count
);
const items =
document.createElement(
'div'
);
items.className =
'stall-compare-tray-items';
const actions =
document.createElement(
'div'
);
actions.className =
'stall-compare-tray-actions';
const clearButton =
document.createElement(
'button'
);
clearButton.type =
'button';
clearButton.className =
'stall-compare-clear';
clearButton.textContent =
'すべて外す';
const compareLink =
document.createElement(
'a'
);
compareLink.className =
'stall-compare-open';
compareLink.href =
mw.util.getUrl(
'屋台比較'
);
compareLink.textContent =
'比較する';
actions.appendChild(
clearButton
);
actions.appendChild(
compareLink
);
tray.appendChild(
header
);
tray.appendChild(
items
);
tray.appendChild(
actions
);
document.body.appendChild(
tray
);
}
/* =====================================
* 比較トレイ更新
* ===================================== */
function updateCompareTray() {
createCompareTray();
const tray =
document.getElementById(
'stall-compare-tray'
);
if ( !tray ) {
return;
}
const ids =
getComparePlacements();
const count =
tray.querySelector(
'.stall-compare-tray-count'
);
const items =
tray.querySelector(
'.stall-compare-tray-items'
);
const compareLink =
tray.querySelector(
'.stall-compare-open'
);
if ( count ) {
count.textContent =
ids.length +
' / ' +
MAX_COMPARE;
}
if ( ids.length === 0 ) {
tray.classList.remove(
'stall-compare-tray-visible'
);
if ( items ) {
items.innerHTML = '';
}
return;
}
tray.classList.add(
'stall-compare-tray-visible'
);
if ( compareLink ) {
if ( ids.length >= 2 ) {
compareLink.classList.remove(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'false'
);
} else {
compareLink.classList.add(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'true'
);
}
}
if ( !items ) {
return;
}
items.textContent =
'屋台情報を読み込み中…';
fetchPlacementInfo(
ids
).then(
function ( placementInfo ) {
const currentIds =
getComparePlacements();
items.innerHTML =
'';
currentIds.forEach(
function ( placementId ) {
const info =
placementInfo[
placementId
];
if ( !info ) {
return;
}
const item =
document.createElement(
'div'
);
item.className =
'stall-compare-tray-item';
const text =
document.createElement(
'div'
);
const name =
document.createElement(
info.stall &&
info.stall.page_name
? 'a'
: 'span'
);
if (
info.stall &&
info.stall.page_name
) {
name.href =
mw.util.getUrl(
info.stall.page_name
);
}
name.className =
'stall-compare-tray-item-name';
name.textContent =
info.stall
? info.stall.stall_name
: '屋台';
const context =
document.createElement(
'div'
);
context.className =
'stall-compare-tray-item-context';
const parts = [];
if (
info.placement &&
info.placement.year
) {
parts.push(
info.placement.year +
'年'
);
}
if (
info.festival &&
info.festival.festival_name
) {
parts.push(
info.festival
.festival_name
);
}
if (
info.venue &&
info.venue.venue_name
) {
parts.push(
info.venue
.venue_name
);
}
context.textContent =
parts.join(
' / '
);
text.appendChild(
name
);
text.appendChild(
context
);
const removeButton =
document.createElement(
'button'
);
removeButton.type =
'button';
removeButton.className =
'stall-compare-tray-remove';
removeButton.dataset.placementId =
placementId;
removeButton.setAttribute(
'aria-label',
(
info.stall
? info.stall.stall_name
: '屋台'
) +
'を比較候補から外す'
);
removeButton.textContent =
'×';
item.appendChild(
text
);
item.appendChild(
removeButton
);
items.appendChild(
item
);
}
);
}
).catch(
function ( error ) {
console.error(
'比較トレイ取得エラー:',
error
);
items.textContent =
'比較候補を読み込めませんでした。';
}
);
}
/* =====================================
* 詳細ページ
* 「比較に追加」
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-button'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
if (
!/^\d+$/.test(
placementId
)
) {
return;
}
const control =
button.closest(
'.stall-compare-control'
);
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
/*
* すでに選択中
*/
if ( index !== -1 ) {
ids.splice(
index,
1
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補から外しました。',
false
);
}
return;
}
/*
* 最大4件
*/
if (
ids.length >=
MAX_COMPARE
) {
if ( control ) {
showMessage(
control,
'比較できる出店は最大4件です。',
true
);
}
return;
}
ids.push(
placementId
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補に追加しました(' +
ids.length +
'/4)。',
false
);
}
}
);
/* =====================================
* トレイから1件削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-tray-remove'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
let ids =
getComparePlacements();
ids =
ids.filter(
function ( id ) {
return id !== placementId;
}
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 全削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-clear'
);
if ( !button ) {
return;
}
event.preventDefault();
saveComparePlacements(
[]
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 1件時の比較リンク無効
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'.stall-compare-open-disabled'
);
if ( !link ) {
return;
}
event.preventDefault();
}
);
/* =====================================
* 初期化
* ===================================== */
function initPlacementCompare() {
createCompareButtons();
createCompareTray();
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
initPlacementCompare();
mw.hook(
'wikipage.content'
).add(
function () {
initPlacementCompare();
}
);
/* =====================================
* 地図popup 比較リンク
* ===================================== */
function getMapComparePlacementId( link ) {
if ( !link ) {
return '';
}
const href =
link.getAttribute( 'href' ) || '';
const match =
href.match(
/#compare-placement-(\d+)$/
);
if ( !match ) {
return '';
}
return match[ 1 ];
}
function updateMapCompareLinks() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'a[href*="#compare-placement-"]'
)
.forEach(
function ( link ) {
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
const selected =
ids.includes(
placementId
);
link.classList.add(
'stall-map-compare-link'
);
link.dataset.placementId =
placementId;
if ( selected ) {
link.textContent =
'比較から外す';
link.classList.add(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'true'
);
} else {
link.textContent =
'比較に追加';
link.classList.remove(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 地図popupクリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'a[href*="#compare-placement-"]'
);
if ( !link ) {
return;
}
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
event.preventDefault();
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
if ( index !== -1 ) {
ids.splice(
index,
1
);
} else {
if (
ids.length >=
MAX_COMPARE
) {
link.textContent =
'最大4件までです';
return;
}
ids.push(
placementId
);
}
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* Leaflet popup生成監視
* ===================================== */
const mapCompareLinkObserver =
new MutationObserver(
function ( mutations ) {
let needsUpdate =
false;
mutations.forEach(
function ( mutation ) {
mutation.addedNodes.forEach(
function ( node ) {
if (
node.nodeType !== 1
) {
return;
}
if (
node.matches &&
node.matches(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
return;
}
if (
node.querySelector &&
node.querySelector(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
}
}
);
}
);
if ( needsUpdate ) {
updateMapCompareLinks();
}
}
);
mapCompareLinkObserver.observe(
document.body,
{
childList: true,
subtree: true
}
);
/*
* ★ この } ); が
* placement比較用mw.loaderの終了
*/
} );
/* ========================================
* 祭りページ
* 屋台名・商品名検索
* ======================================== */
mw.loader.using( [
'mediawiki.api'
] ).then( function () {
'use strict';
const cards =
Array.from(
document.querySelectorAll(
'.festival-stall-search-card'
)
);
/*
* 屋台カードがないページでは終了
*/
if (
cards.length === 0
) {
return;
}
const api =
new mw.Api();
/* =====================================
* 文字列正規化
* ===================================== */
function normalizeSearchText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
/*
* 全角・半角を可能な範囲で統一
*/
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
/*
* 大文字小文字を統一
*/
text =
text.toLowerCase();
/*
* 連続空白を統一
*/
text =
text.replace(
/\s+/g,
' '
);
return text;
}
/* =====================================
* Cargo Query
* ===================================== */
function cargoQuery(
table,
fields,
where
) {
const params = {
action:
'cargoquery',
tables:
table,
fields:
fields,
limit:
500,
format:
'json'
};
if (
where
) {
params.where =
where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return (
item.title ||
item
);
}
);
}
);
}
/* =====================================
* placement_id一覧
* ===================================== */
const placementIds =
cards
.map(
function ( card ) {
return String(
card.dataset
.placementId ||
''
);
}
)
.filter(
function ( id ) {
return (
/^\d+$/.test(
id
) &&
id !== '0'
);
}
);
/*
* 地図markerを持つplacementだけを
* 一覧用placementIdsとは分離して管理
*/
const mapPlacementIds =
cards
.filter(
function ( card ) {
const latitude =
String(
card.dataset
.latitude ||
''
).trim();
const longitude =
String(
card.dataset
.longitude ||
''
).trim();
return (
latitude !== '' &&
longitude !== ''
);
}
)
.map(
function ( card ) {
return String(
card.dataset
.placementId ||
''
);
}
)
.filter(
function ( id ) {
return (
/^\d+$/.test(
id
) &&
id !== '0'
);
}
);
if (
placementIds.length === 0
) {
return;
}
/* =====================================
* 屋台一覧 ↔ Leaflet地図連動
*
* placement_id を使って
* markerを表示・非表示
* ===================================== */
const festivalMapMarkerIndex =
{};
/*
* 地図markerの登録が完了したか
*/
let festivalMapMarkerIndexReady =
false;
/*
* R10-5C ISSUE-07:
* Festival地図の初期viewportを
* marker群へ合わせたか。
*/
let festivalMapInitialViewportApplied =
false;
/*
* R10-5C7:
* Maps拡張の初期center/zoom処理が
* 完了した次taskでviewportを適用する。
*/
let festivalMapInitialViewportTimer =
null;
let festivalMapInitialViewportAttempts =
0;
const MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS =
40;
/*
* 地図生成前に検索された場合に備えて
* 最新の表示対象を保持
*/
let pendingVisiblePlacementIds =
placementIds.slice();
/*
* 地図初期化待ち
*/
let mapIndexTimer =
null;
let mapIndexAttempts =
0;
const MAX_MAP_INDEX_ATTEMPTS =
40;
/* =====================================
* marker popupから
* placement_idを取得
*
* popup内には
* #compare-placement-3
* のようなリンクが存在する
* ===================================== */
function getPlacementIdFromMapMarker(
marker
) {
if (
!marker ||
typeof marker.getPopup !==
'function'
) {
return '';
}
const popup =
marker.getPopup();
if (
!popup ||
typeof popup.getContent !==
'function'
) {
return '';
}
const content =
popup.getContent();
let popupText =
'';
/*
* 通常はHTML文字列
*/
if (
typeof content ===
'string'
) {
popupText =
content;
} else if (
content &&
typeof content.outerHTML ===
'string'
) {
popupText =
content.outerHTML;
} else if (
content &&
typeof content.innerHTML ===
'string'
) {
popupText =
content.innerHTML;
}
const match =
popupText.match(
/compare-placement-(\d+)/
);
if (
!match
) {
return '';
}
return match[
1
];
}
/* =====================================
* Maps拡張のmarkerを
* placement_idごとに登録
* ===================================== */
function buildFestivalMapMarkerIndex() {
if (
festivalMapMarkerIndexReady
) {
scheduleFestivalMapInitialViewport();
return true;
}
/*
* 座標付きplacementが0件なら
* marker indexは0件で正常完了
*/
if (
mapPlacementIds.length === 0
) {
festivalMapMarkerIndexReady =
true;
return true;
}
if (
!Array.isArray(
window.mapsLeafletList
)
) {
return false;
}
const expectedIds =
new Set(
mapPlacementIds.map(
String
)
);
window.mapsLeafletList.forEach(
function ( jqueryMap ) {
if (
!jqueryMap ||
!jqueryMap.mapContent ||
!jqueryMap.mapContent
.markerLayer
) {
return;
}
const markerLayer =
jqueryMap
.mapContent
.markerLayer;
if (
typeof markerLayer
.getLayers !==
'function'
) {
return;
}
const markers =
markerLayer.getLayers();
markers.forEach(
function ( marker ) {
const placementId =
getPlacementIdFromMapMarker(
marker
);
/*
* この祭りページの
* placementだけ登録
*/
if (
!placementId ||
!expectedIds.has(
placementId
)
) {
return;
}
festivalMapMarkerIndex[
placementId
] = {
marker:
marker,
markerLayer:
markerLayer,
/*
* このmarkerが所属する地図DOM
*/
mapElement:
jqueryMap &&
jqueryMap[
0
]
? jqueryMap[
0
]
: null
};
}
);
}
);
/*
* 全placementのmarkerが
* 見つかった場合だけ連動開始
*
* 中途半端な状態では
* 地図を変更しない
*/
festivalMapMarkerIndexReady =
Array.from(
expectedIds
).every(
function (
placementId
) {
return Boolean(
festivalMapMarkerIndex[
placementId
]
);
}
);
if (
festivalMapMarkerIndexReady
) {
scheduleFestivalMapInitialViewport();
}
return (
festivalMapMarkerIndexReady
);
}
/*
* =====================================
* R10-5C ISSUE-07
* Festival「地図から探す」初期viewport
*
* 0 marker:
* 現行fallbackを維持
*
* 1 marker:
* marker中央、zoom上限17
*
* 2 marker以上:
* 全markerをfitBounds
* padding 32px
* maxZoom 17
* =====================================
*/
function scheduleFestivalMapInitialViewport() {
if (
festivalMapInitialViewportApplied ||
!festivalMapMarkerIndexReady
) {
return false;
}
/*
* 二重timer防止。
*/
if (
festivalMapInitialViewportTimer !==
null
) {
return true;
}
/*
* Festival地図に属するmarkerから
* 対象Leaflet mapを特定する。
*/
const indexedItem =
mapPlacementIds
.map(
String
)
.map(
function (
placementId
) {
return (
festivalMapMarkerIndex[
placementId
] ||
null
);
}
)
.find(
function ( item ) {
return Boolean(
item &&
item.marker &&
item.markerLayer &&
item.markerLayer._map
);
}
);
/*
* 座標付きplacement自体が0件なら
* Maps側のfallbackを正式採用して完了。
*
* placementが存在するのにindexedItemが
* まだ取れない場合は初期化途中なので、
* applied=trueにせず再試行する。
*/
if (
!indexedItem
) {
if (
mapPlacementIds.length ===
0
) {
festivalMapInitialViewportApplied =
true;
return true;
}
festivalMapInitialViewportAttempts +=
1;
if (
festivalMapInitialViewportAttempts >=
MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
) {
console.warn(
'祭り屋台地図:markerのLeaflet map接続を確認できなかったため、初期viewport調整を中止しました。'
);
return false;
}
festivalMapInitialViewportTimer =
window.setTimeout(
function () {
festivalMapInitialViewportTimer =
null;
scheduleFestivalMapInitialViewport();
},
100
);
return true;
}
const targetMap =
indexedItem
.markerLayer
._map;
const mapsEntry =
Array.isArray(
window.mapsLeafletList
)
? window.mapsLeafletList
.find(
function ( entry ) {
return Boolean(
entry &&
entry.map ===
targetMap
);
}
)
: null;
/*
* Maps setup() は doSetup() 冒頭で
* ranSetup=true にした後、
* centerAndZoomMap() を実行する。
*
* ranSetup=trueでも同一call stack中なら
* Maps側のzoom=18がまだ後続するため、
* 必ず次taskへ送る。
*
* ranSetup前なら100ms単位で待機する。
*/
const delay =
mapsEntry &&
mapsEntry.ranSetup ===
true
? 0
: 100;
festivalMapInitialViewportTimer =
window.setTimeout(
function () {
festivalMapInitialViewportTimer =
null;
/*
* timer実行時点でもMaps setupが
* 完了していなければ再試行。
*/
if (
!mapsEntry ||
mapsEntry.ranSetup !==
true
) {
festivalMapInitialViewportAttempts +=
1;
if (
festivalMapInitialViewportAttempts >=
MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
) {
console.warn(
'祭り屋台地図:Maps初期化完了を確認できなかったため、初期viewport調整を中止しました。'
);
return;
}
scheduleFestivalMapInitialViewport();
return;
}
if (
applyFestivalMapInitialViewport()
) {
festivalMapInitialViewportAttempts =
0;
return;
}
festivalMapInitialViewportAttempts +=
1;
if (
festivalMapInitialViewportAttempts >=
MAX_FESTIVAL_MAP_VIEWPORT_ATTEMPTS
) {
console.warn(
'祭り屋台地図:初期viewportを適用できなかったため、再試行を中止しました。'
);
return;
}
scheduleFestivalMapInitialViewport();
},
delay
);
return true;
}
function applyFestivalMapInitialViewport() {
if (
festivalMapInitialViewportApplied ||
!festivalMapMarkerIndexReady
) {
return false;
}
if (
typeof L === 'undefined'
) {
return false;
}
const items =
mapPlacementIds
.map(
String
)
.map(
function (
placementId
) {
return (
festivalMapMarkerIndex[
placementId
] ||
null
);
}
)
.filter(
function ( item ) {
return Boolean(
item &&
item.marker &&
typeof item.marker
.getLatLng ===
'function' &&
item.markerLayer
);
}
);
/*
* mapPlacementIdsが存在する状態で
* itemsが0件なのは初期化途中。
*
* applied=trueにはせず、
* schedulerへfalseを返して再試行させる。
*/
if (
items.length === 0
) {
return false;
}
const map =
items[
0
].markerLayer &&
items[
0
].markerLayer._map
? items[
0
].markerLayer._map
: null;
if (
!map ||
typeof map.setView !==
'function' ||
typeof map.fitBounds !==
'function'
) {
return false;
}
/*
* 同一Festival地図に属するmarkerだけを
* viewport計算へ使用。
*/
const latLngs =
items
.filter(
function ( item ) {
return (
item.markerLayer &&
item.markerLayer._map ===
map
);
}
)
.map(
function ( item ) {
return item.marker
.getLatLng();
}
)
.filter(
function ( latlng ) {
return Boolean(
latlng &&
Number.isFinite(
Number(
latlng.lat
)
) &&
Number.isFinite(
Number(
latlng.lng
)
)
);
}
);
if (
latLngs.length === 0
) {
return false;
}
if (
latLngs.length === 1
) {
map.setView(
latLngs[
0
],
17,
{
animate:
false
}
);
} else {
map.fitBounds(
L.latLngBounds(
latLngs
),
{
padding:
[
32,
32
],
maxZoom:
17,
animate:
false
}
);
}
festivalMapInitialViewportApplied =
true;
return true;
}
/* =====================================
* marker表示状態を変更
* ===================================== */
function applyMapMarkerFilter(
visiblePlacementIds
) {
const visibleIds =
new Set(
visiblePlacementIds.map(
String
)
);
Object.keys(
festivalMapMarkerIndex
).forEach(
function ( placementId ) {
const item =
festivalMapMarkerIndex[
placementId
];
if (
!item ||
!item.marker ||
!item.markerLayer
) {
return;
}
const marker =
item.marker;
const markerLayer =
item.markerLayer;
/*
* markerが現在表示されているか
*/
const isShown =
typeof markerLayer
.hasLayer ===
'function'
? markerLayer.hasLayer(
marker
)
: true;
/*
* 表示対象
*/
if (
visibleIds.has(
placementId
)
) {
if (
!isShown &&
typeof markerLayer
.addLayer ===
'function'
) {
markerLayer.addLayer(
marker
);
}
/*
* 非表示対象
*/
} else {
if (
isShown &&
typeof markerLayer
.removeLayer ===
'function'
) {
markerLayer.removeLayer(
marker
);
}
}
}
);
}
/* =====================================
* 地図初期化待ち
* ===================================== */
function scheduleMapMarkerIndex() {
if (
festivalMapMarkerIndexReady
) {
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
/*
* 二重タイマー防止
*/
if (
mapIndexTimer !== null
) {
return;
}
function tryIndex() {
mapIndexTimer =
null;
if (
buildFestivalMapMarkerIndex()
) {
/*
* 地図準備完了後、
* 最新の絞り込み状態を反映
*/
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
mapIndexAttempts +=
1;
if (
mapIndexAttempts >=
MAX_MAP_INDEX_ATTEMPTS
) {
console.warn(
'祭り屋台地図:placement_idとmarkerを対応付けできませんでした。'
);
return;
}
mapIndexTimer =
window.setTimeout(
tryIndex,
100
);
}
tryIndex();
}
/* =====================================
* 一覧の検索結果を
* 地図へ反映
* ===================================== */
function syncMapMarkers(
visiblePlacementIds
) {
pendingVisiblePlacementIds =
visiblePlacementIds
.map(
String
);
if (
buildFestivalMapMarkerIndex()
) {
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
/*
* Maps側がまだ初期化されていれば待つ
*/
scheduleMapMarkerIndex();
}
/* =====================================
* placement_idのmarkerを開く
* ===================================== */
function openPlacementOnMap(
placementId
) {
const id =
String(
placementId ||
''
);
if (
!/^\d+$/.test(
id
) ||
id === '0'
) {
return false;
}
/*
* marker index未完成なら
* 一度構築を試す
*/
if (
!festivalMapMarkerIndex[
id
]
) {
buildFestivalMapMarkerIndex();
}
const item =
festivalMapMarkerIndex[
id
];
if (
!item ||
!item.marker
) {
console.warn(
'地図markerが見つかりません:',
id
);
return false;
}
const marker =
item.marker;
/*
* 万一markerが非表示なら
* 地図へ戻す
*/
if (
item.markerLayer &&
typeof item.markerLayer
.hasLayer ===
'function' &&
!item.markerLayer.hasLayer(
marker
) &&
typeof item.markerLayer
.addLayer ===
'function'
) {
item.markerLayer.addLayer(
marker
);
}
/*
* 地図までスクロール
*/
if (
item.mapElement &&
typeof item.mapElement
.scrollIntoView ===
'function'
) {
item.mapElement.scrollIntoView(
{
behavior:
'smooth',
block:
'center'
}
);
}
/*
* 少し待ってpopupを開く
*/
window.setTimeout(
function () {
if (
typeof marker.openPopup ===
'function'
) {
marker.openPopup();
}
},
300
);
return true;
}
/* =====================================
* 各屋台カード
* 「地図で見る」ボタン生成
* ===================================== */
function createMapViewButtons() {
cards.forEach(
function ( card ) {
/*
* 二重生成防止
*/
if (
card.querySelector(
'.festival-stall-map-view'
)
) {
return;
}
const placementId =
String(
card.dataset
.placementId ||
''
);
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
/*
* 座標なしplacementには
* 地図ボタンを表示しない
*/
if (
!mapPlacementIds.includes(
placementId
)
) {
return;
}
const wrapper =
document.createElement(
'div'
);
wrapper.className =
'festival-stall-map-view';
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'festival-stall-map-view-button';
button.dataset.placementId =
placementId;
button.textContent =
'地図で見る';
button.setAttribute(
'aria-label',
'この屋台を地図で見る'
);
wrapper.appendChild(
button
);
/*
* 比較ボタンの近くへ配置
*/
const compareControl =
card.querySelector(
'.stall-compare-control'
);
if (
compareControl &&
compareControl.parentNode
) {
compareControl.parentNode
.insertBefore(
wrapper,
compareControl
.nextSibling
);
} else {
/*
* 比較ボタンが見つからない場合は
* カード末尾
*/
card.appendChild(
wrapper
);
}
}
);
}
/* =====================================
* 「地図で見る」クリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.festival-stall-map-view-button'
);
if (
!button
) {
return;
}
const placementId =
String(
button.dataset
.placementId ||
''
);
const opened =
openPlacementOnMap(
placementId
);
/*
* Maps初期化前だった場合
*/
if (
!opened
) {
scheduleMapMarkerIndex();
button.disabled =
true;
button.textContent =
'地図を準備中…';
window.setTimeout(
function () {
button.disabled =
false;
button.textContent =
'地図で見る';
openPlacementOnMap(
placementId
);
},
500
);
}
}
);
/* =====================================
* 検索UI
* ===================================== */
const searchBox =
document.createElement(
'div'
);
searchBox.className =
'festival-stall-search';
const label =
document.createElement(
'label'
);
label.className =
'festival-stall-search-label';
label.textContent =
'屋台を検索';
const input =
document.createElement(
'input'
);
input.type =
'search';
input.className =
'festival-stall-search-input';
input.placeholder =
'屋台名・商品名を入力';
input.setAttribute(
'autocomplete',
'off'
);
input.setAttribute(
'aria-label',
'屋台名または商品名で検索'
);
/* =====================================
* フィルターselect
* ===================================== */
function createFilterSelect(
labelText,
className,
allText
) {
const wrapper =
document.createElement(
'label'
);
wrapper.className =
'festival-stall-filter';
const title =
document.createElement(
'span'
);
title.className =
'festival-stall-filter-label';
title.textContent =
labelText;
const select =
document.createElement(
'select'
);
select.className =
'festival-stall-filter-select ' +
className;
const allOption =
document.createElement(
'option'
);
allOption.value =
'';
allOption.textContent =
allText;
select.appendChild(
allOption
);
wrapper.appendChild(
title
);
wrapper.appendChild(
select
);
return {
wrapper:
wrapper,
select:
select
};
}
/*
* カテゴリ
*/
const categoryFilter =
createFilterSelect(
'カテゴリ',
'festival-stall-category-filter',
'すべて'
);
/*
* 会場
*/
const venueFilter =
createFilterSelect(
'会場',
'festival-stall-venue-filter',
'すべて'
);
const categorySelect =
categoryFilter.select;
const venueSelect =
venueFilter.select;
/*
* フィルター行
*/
const filterRow =
document.createElement(
'div'
);
filterRow.className =
'festival-stall-search-filters';
filterRow.appendChild(
categoryFilter.wrapper
);
filterRow.appendChild(
venueFilter.wrapper
);
/*
* 絞り込みリセット
* ===================================== */
const resetButton =
document.createElement(
'button'
);
resetButton.type =
'button';
resetButton.className =
'festival-stall-search-reset';
resetButton.textContent =
'絞り込みをリセット';
resetButton.setAttribute(
'aria-label',
'屋台の検索条件をすべてリセット'
);
resetButton.disabled =
true;
const count =
document.createElement(
'div'
);
count.className =
'festival-stall-search-count';
/* =====================================
* 検索結果0件メッセージ
* ===================================== */
const noResults =
document.createElement(
'div'
);
noResults.className =
'festival-stall-search-empty';
noResults.textContent =
'条件に一致する屋台はありません。検索条件を変更してください。';
noResults.hidden =
true;
noResults.setAttribute(
'role',
'status'
);
label.appendChild(
input
);
searchBox.appendChild(
label
);
searchBox.appendChild(
filterRow
);
/*
* リセット
*/
searchBox.appendChild(
resetButton
);
searchBox.appendChild(
count
);
searchBox.appendChild(
noResults
);
/*
* 最初の屋台カードの直前に表示
*/
/*
* 検索UIの表示位置
*/
const searchAnchor =
document.getElementById(
'festival-stall-search-anchor'
);
if (
searchAnchor
) {
searchAnchor.appendChild(
searchBox
);
} else {
/*
* 古いテンプレート等への
* フォールバック
*/
cards[
0
].parentNode.insertBefore(
searchBox,
cards[
0
]
);
}
/* =====================================
* カードごとの検索文字列
*
* 最初はカード本文だけ
* ===================================== */
const searchIndex = {};
/*
* select候補
*/
const categoryOptions =
new Map();
const venueOptions =
new Map();
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const category =
String(
card.dataset
.category ||
''
).trim();
const venueName =
String(
card.dataset
.venueName ||
''
).trim();
const normalizedCategory =
normalizeSearchText(
category
);
const normalizedVenue =
normalizeSearchText(
venueName
);
/*
* placementごとの検索情報
*/
searchIndex[
placementId
] = {
text:
normalizeSearchText(
card.textContent
),
category:
normalizedCategory,
venueName:
normalizedVenue
};
/*
* カテゴリselect候補
*/
if (
normalizedCategory &&
!categoryOptions.has(
normalizedCategory
)
) {
categoryOptions.set(
normalizedCategory,
category
);
}
/*
* 会場select候補
*/
if (
normalizedVenue &&
!venueOptions.has(
normalizedVenue
)
) {
venueOptions.set(
normalizedVenue,
venueName
);
}
}
);
/* =====================================
* select option生成
* ===================================== */
function fillFilterOptions(
select,
optionMap
) {
const options =
Array.from(
optionMap.entries()
);
/*
* 表示名で並び替え
*/
options.sort(
function ( a, b ) {
return a[
1
].localeCompare(
b[
1
],
'ja'
);
}
);
options.forEach(
function ( optionData ) {
const value =
optionData[
0
];
const label =
optionData[
1
];
const option =
document.createElement(
'option'
);
option.value =
value;
option.textContent =
label;
select.appendChild(
option
);
}
);
}
fillFilterOptions(
categorySelect,
categoryOptions
);
fillFilterOptions(
venueSelect,
venueOptions
);
/* =====================================
* 件数表示
* ===================================== */
function updateCount(
visible
) {
count.textContent =
'表示:' +
visible +
' / ' +
cards.length +
'件';
}
updateCount(
cards.length
);
/* =====================================
* 検索実行
* ===================================== */
function applySearch() {
/*
* フリーワード
*/
const keyword =
normalizeSearchText(
input.value
);
/*
* カテゴリ
*/
const selectedCategory =
categorySelect.value;
/*
* 会場
*/
const selectedVenue =
venueSelect.value;
let visible =
0;
/*
* 地図に残すplacement_id
*/
const visiblePlacementIds =
[];
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const index =
searchIndex[
placementId
] || {
text:
'',
category:
'',
venueName:
''
};
/* =============================
* フリーワード
* ============================= */
const keywordMatched =
!keyword ||
index.text.includes(
keyword
);
/* =============================
* カテゴリ
* ============================= */
const categoryMatched =
!selectedCategory ||
index.category ===
selectedCategory;
/* =============================
* 会場
* ============================= */
const venueMatched =
!selectedVenue ||
index.venueName ===
selectedVenue;
/* =============================
* AND条件
* ============================= */
const matched =
keywordMatched &&
categoryMatched &&
venueMatched;
if (
matched
) {
card.style.display =
'';
visible +=
1;
/*
* 地図にも残す
*/
visiblePlacementIds.push(
placementId
);
} else {
card.style.display =
'none';
}
}
);
updateCount(
visible
);
/*
* 0件メッセージ
*/
noResults.hidden =
visible !== 0;
/*
* 検索条件が1つでもあれば
* リセットボタンを有効化
*/
resetButton.disabled =
(
normalizeSearchText(
input.value
) === '' &&
categorySelect.value === '' &&
venueSelect.value === ''
);
/*
* 地図を一覧と同期
*/
syncMapMarkers(
visiblePlacementIds
);
}
/*
* 各カードへ
* 地図で見るボタン
*/
createMapViewButtons();
/*
* 初期状態
*
* 最初は全placementを表示
*/
syncMapMarkers(
placementIds
);
input.addEventListener(
'input',
applySearch
);
categorySelect.addEventListener(
'change',
applySearch
);
venueSelect.addEventListener(
'change',
applySearch
);
/* =====================================
* 絞り込みをすべてリセット
* ===================================== */
resetButton.addEventListener(
'click',
function () {
/*
* フリーワード
*/
input.value =
'';
/*
* カテゴリ
*/
categorySelect.value =
'';
/*
* 会場
*/
venueSelect.value =
'';
/*
* 一覧・件数・0件表示・
* 地図markerをすべて再計算
*/
applySearch();
/*
* 続けて検索しやすくする
*/
input.focus();
}
);
/* =====================================
* Placement → Offering取得
* ===================================== */
cargoQuery(
'FestivalStallMenuOfferings',
'placement_id=placement_id,' +
'menu_item_id=menu_item_id',
'placement_id IN (' +
placementIds.join(
','
) +
')'
).then(
function ( offerings ) {
const menuItemIds =
[
...new Set(
offerings
.map(
function (
offering
) {
return String(
offering
.menu_item_id ||
''
);
}
)
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
)
];
/*
* メニューが1件も無い
*/
if (
menuItemIds.length === 0
) {
return {
offerings:
offerings,
menus:
[]
};
}
/* =================================
* MenuItem名取得
* ================================= */
return cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'name=menu_name',
'menu_item_id IN (' +
menuItemIds.join(
','
) +
')'
).then(
function ( menus ) {
return {
offerings:
offerings,
menus:
menus
};
}
);
}
).then(
function ( data ) {
if (
!data
) {
return;
}
/* =================================
* menu_item_id → 商品名
* ================================= */
const menuNameMap =
{};
data.menus.forEach(
function ( menu ) {
menuNameMap[
String(
menu.menu_item_id
)
] =
menu.menu_name ||
'';
}
);
/* =================================
* placement_id → 商品名[]
* ================================= */
const placementMenus =
{};
data.offerings.forEach(
function ( offering ) {
const placementId =
String(
offering
.placement_id ||
''
);
const menuItemId =
String(
offering
.menu_item_id ||
''
);
const menuName =
menuNameMap[
menuItemId
] || '';
if (
!menuName
) {
return;
}
if (
!placementMenus[
placementId
]
) {
placementMenus[
placementId
] = [];
}
placementMenus[
placementId
].push(
menuName
);
}
);
/* =================================
* 商品名を検索インデックスへ追加
* ================================= */
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const menuNames =
placementMenus[
placementId
] || [];
if (
searchIndex[
placementId
]
) {
searchIndex[
placementId
].text =
normalizeSearchText(
(
searchIndex[
placementId
].text ||
''
) +
' ' +
menuNames.join(
' '
)
);
}
}
);
/*
* 商品データ取得後、
* 入力済み検索を再判定
*/
applySearch();
}
).catch(
function ( error ) {
/*
* 商品データ取得に失敗しても
* 屋台名検索は使えるようにする
*/
console.error(
'屋台商品検索データ取得エラー:',
error
);
}
);
} );
/* ========================================
* 屋台比較ページ
* placement_id 正式版
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const compareRoot =
document.getElementById(
'stall-compare-page'
);
/*
* 屋台比較ページ以外では終了
*/
if ( !compareRoot ) {
return;
}
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MIN_COMPARE = 2;
const MAX_COMPARE = 4;
const api =
new mw.Api();
/* =====================================
* localStorage
* ===================================== */
function getPlacementIds() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse(
raw
);
if (
!Array.isArray(
ids
)
) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
/* =====================================
* Cargo
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return (
item.title ||
item
);
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return (
id &&
/^\d+$/.test(
id
)
);
}
)
)
];
}
function mapBy(
rows,
key
) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] ===
undefined
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* 表示ヘルパー
* ===================================== */
function textOrDash(
value
) {
if (
value === undefined ||
value === null ||
value === ''
) {
return '―';
}
return String(
value
);
}
function cleanNumber(
value
) {
if (
value === undefined ||
value === null ||
String( value ).trim() === ''
) {
return '';
}
const number =
Number(
value
);
if (
!Number.isFinite(
number
)
) {
return '';
}
if (
Number.isInteger(
number
)
) {
return String(
number
);
}
return String(
Math.round(
number * 100
) / 100
);
}
/* =====================================
* 比較計算用数値
* ===================================== */
function toFiniteNumber(
value
) {
if (
value === undefined ||
value === null ||
String( value ).trim() === ''
) {
return null;
}
const number =
Number(
value
);
if (
!Number.isFinite(
number
)
) {
return null;
}
return number;
}
function formatHours(
placement
) {
if ( !placement ) {
return '―';
}
const open =
placement.opening_time || '';
const close =
placement.closing_time || '';
if (
open &&
close
) {
return (
open +
'~' +
close
);
}
if ( open ) {
return (
open +
'~'
);
}
if ( close ) {
return (
'~' +
close
);
}
if (
placement.hours_note
) {
return placement.hours_note;
}
return '未確認';
}
function formatPositionStatus(
status
) {
switch ( status ) {
case 'exact':
return '正確な位置';
case 'approximate':
return 'おおよその位置';
default:
return '位置未確認';
}
}
function formatVerification(
status
) {
switch ( status ) {
case 'verified':
return '確認済み';
case 'partially_verified':
return '一部確認済み';
case 'outdated':
return '情報が古い';
default:
return '未確認';
}
}
function formatAvailability(
status
) {
switch ( status ) {
case 'available':
return '販売あり';
case 'unavailable':
return '販売なし';
default:
return '未確認';
}
}
/* =====================================
* 単位価格
* 表示用
* ===================================== */
function getUnitPrice(
offering
) {
const price =
toFiniteNumber(
offering.price
);
const quantity =
toFiniteNumber(
offering.serving_quantity
);
if (
price === null ||
quantity === null ||
quantity <= 0
) {
return '―';
}
const unitPrice =
Math.round(
(
price /
quantity
) *
100
) /
100;
const unit =
offering.serving_unit ||
'単位';
return (
unitPrice +
'円/' +
unit
);
}
/* =====================================
* 単位価格
* 比較計算用
* ===================================== */
function getUnitPriceValue(
offering
) {
const price =
toFiniteNumber(
offering.price
);
const quantity =
toFiniteNumber(
offering.serving_quantity
);
if (
price === null ||
quantity === null ||
quantity <= 0
) {
return null;
}
return (
price /
quantity
);
}
/* =====================================
* DOM
* ===================================== */
function createTextCell(
tagName,
text
) {
const cell =
document.createElement(
tagName
);
cell.textContent =
text;
return cell;
}
/* =====================================
* メニュー
* ===================================== */
function createMenuList(
menus
) {
const container =
document.createElement(
'div'
);
container.className =
'stall-compare-menu-list';
if (
!menus ||
menus.length === 0
) {
container.textContent =
'メニュー未登録';
return container;
}
menus.forEach(
function ( item ) {
const menu =
document.createElement(
'div'
);
menu.className =
'stall-compare-menu-item';
const name =
document.createElement(
'strong'
);
name.className =
'stall-compare-menu-name';
name.textContent =
item.menuName ||
'商品';
const price =
document.createElement(
'div'
);
price.className =
'stall-compare-menu-price';
price.textContent =
item.price
? item.price +
'円'
: '価格未確認';
/*
* 最安価格
*/
if (
item.isLowestPrice
) {
const badge =
document.createElement(
'span'
);
badge.className =
'stall-compare-best-badge ' +
'stall-compare-best-price';
badge.textContent =
'最安価格';
price.appendChild(
document.createTextNode(
' '
)
);
price.appendChild(
badge
);
}
const serving =
document.createElement(
'div'
);
if (
item.servingQuantity
) {
serving.textContent =
'内容量:' +
item.servingQuantity +
(
item.servingUnit ||
''
);
} else {
serving.textContent =
'内容量:未確認';
}
const unit =
document.createElement(
'div'
);
unit.className =
'stall-compare-menu-unit-price';
unit.textContent =
'1単位あたり:' +
item.unitPrice;
/*
* 最安単位価格
*/
if (
item.isLowestUnitPrice
) {
const badge =
document.createElement(
'span'
);
badge.className =
'stall-compare-best-badge ' +
'stall-compare-best-unit-price';
badge.textContent =
'最安単位価格';
unit.appendChild(
document.createTextNode(
' '
)
);
unit.appendChild(
badge
);
}
const availability =
document.createElement(
'div'
);
availability.textContent =
'販売状況:' +
item.availability;
menu.appendChild(
name
);
menu.appendChild(
price
);
menu.appendChild(
serving
);
menu.appendChild(
unit
);
menu.appendChild(
availability
);
container.appendChild(
menu
);
}
);
return container;
}
/* =====================================
* 比較表
* ===================================== */
function renderComparison(
compareData
) {
compareRoot.innerHTML =
'';
const heading =
document.createElement(
'h2'
);
heading.textContent =
'屋台比較';
compareRoot.appendChild(
heading
);
const wrapper =
document.createElement(
'div'
);
wrapper.className =
'stall-compare-table-wrapper';
const table =
document.createElement(
'table'
);
table.className =
'stall-compare-table';
/* ------------------------------
* thead
* ------------------------------ */
const thead =
document.createElement(
'thead'
);
const headerRow =
document.createElement(
'tr'
);
headerRow.appendChild(
createTextCell(
'th',
'比較項目'
)
);
compareData.forEach(
function ( data ) {
const th =
document.createElement(
'th'
);
if (
data.stall &&
data.stall.page_name
) {
const link =
document.createElement(
'a'
);
link.href =
mw.util.getUrl(
data.stall
.page_name
);
link.textContent =
data.stall
.stall_name ||
'屋台';
th.appendChild(
link
);
} else {
th.textContent =
data.stall
? data.stall.stall_name
: '屋台';
}
headerRow.appendChild(
th
);
}
);
thead.appendChild(
headerRow
);
table.appendChild(
thead
);
const tbody =
document.createElement(
'tbody'
);
function addRow(
label,
getter
) {
const tr =
document.createElement(
'tr'
);
const labelCell =
createTextCell(
'th',
label
);
labelCell.scope =
'row';
tr.appendChild(
labelCell
);
compareData.forEach(
function ( data ) {
tr.appendChild(
createTextCell(
'td',
textOrDash(
getter(
data
)
)
)
);
}
);
tbody.appendChild(
tr
);
}
/* =================================
* Placement情報
* ================================= */
addRow(
'開催年',
function ( data ) {
return data.placement
? data.placement.year +
'年'
: '―';
}
);
addRow(
'祭り',
function ( data ) {
return data.festival
? data.festival
.festival_name
: '―';
}
);
addRow(
'会場',
function ( data ) {
return data.venue
? data.venue
.venue_name
: '―';
}
);
addRow(
'地域',
function ( data ) {
return data.area
? data.area
.area_name
: '―';
}
);
addRow(
'カテゴリ',
function ( data ) {
return data.stall
? data.stall
.category
: '―';
}
);
addRow(
'出店場所',
function ( data ) {
return data.placement
? data.placement
.location_note
: '―';
}
);
addRow(
'営業時間',
function ( data ) {
return formatHours(
data.placement
);
}
);
addRow(
'位置情報',
function ( data ) {
return data.placement
? formatPositionStatus(
data.placement
.position_status
)
: '―';
}
);
addRow(
'確認状態',
function ( data ) {
return data.placement
? formatVerification(
data.placement
.verification_status
)
: '―';
}
);
/* =================================
* メニュー
* ================================= */
const menuRow =
document.createElement(
'tr'
);
const menuLabel =
createTextCell(
'th',
'メニュー'
);
menuLabel.scope =
'row';
menuRow.appendChild(
menuLabel
);
compareData.forEach(
function ( data ) {
const td =
document.createElement(
'td'
);
td.appendChild(
createMenuList(
data.menus
)
);
menuRow.appendChild(
td
);
}
);
tbody.appendChild(
menuRow
);
table.appendChild(
tbody
);
wrapper.appendChild(
table
);
compareRoot.appendChild(
wrapper
);
const note =
document.createElement(
'p'
);
note.className =
'stall-compare-note';
note.textContent =
'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';
compareRoot.appendChild(
note
);
}
function renderMessage(
message
) {
compareRoot.innerHTML =
'';
const p =
document.createElement(
'p'
);
p.className =
'stall-compare-page-message';
p.textContent =
message;
compareRoot.appendChild(
p
);
}
/* =====================================
* データ取得
* ===================================== */
const placementIds =
getPlacementIds();
if (
placementIds.length <
MIN_COMPARE
) {
renderMessage(
'比較する出店を2件以上選択してください。'
);
return;
}
/*
* STEP 1
* Placementを直接取得
*/
cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'stall_id=stall_id,' +
'festival_id=festival_id,' +
'venue_id=venue_id,' +
'year=year,' +
'latitude=latitude,' +
'longitude=longitude,' +
'location_note=location_note,' +
'opening_time=opening_time,' +
'closing_time=closing_time,' +
'hours_note=hours_note,' +
'position_status=position_status,' +
'verification_status=verification_status',
'placement_id IN (' +
makeInClause(
placementIds
) +
')',
100
).then(
function ( placements ) {
const stallIds =
uniqueIds(
placements.map(
function ( row ) {
return row.stall_id;
}
)
);
const festivalIds =
uniqueIds(
placements.map(
function ( row ) {
return row.festival_id;
}
)
);
const venueIds =
uniqueIds(
placements.map(
function ( row ) {
return row.venue_id;
}
)
);
/*
* STEP 2
*/
return Promise.all( [
stallIds.length
? cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name,' +
'category=category,' +
'_pageName=page_name',
'stall_id IN (' +
makeInClause(
stallIds
) +
')',
100
)
: Promise.resolve(
[]
),
festivalIds.length
? cargoQuery(
'Festivals',
'festival_id=festival_id,' +
'name=festival_name,' +
'_pageName=page_name',
'festival_id IN (' +
makeInClause(
festivalIds
) +
')',
100
)
: Promise.resolve(
[]
),
venueIds.length
? cargoQuery(
'Venues',
'venue_id=venue_id,' +
'name=venue_name,' +
'area_id=area_id,' +
'_pageName=page_name',
'venue_id IN (' +
makeInClause(
venueIds
) +
')',
100
)
: Promise.resolve(
[]
),
cargoQuery(
'FestivalStallMenuOfferings',
'placement_id=placement_id,' +
'menu_item_id=menu_item_id,' +
'price=price,' +
'serving_quantity=serving_quantity,' +
'serving_unit=serving_unit,' +
'availability=availability,' +
'verification_status=verification_status,' +
'sort_order=sort_order',
'placement_id IN (' +
makeInClause(
placementIds
) +
')',
100
)
] ).then(
function ( results ) {
return {
placements:
placements,
stalls:
results[ 0 ],
festivals:
results[ 1 ],
venues:
results[ 2 ],
offerings:
results[ 3 ]
};
}
);
}
).then(
function ( data ) {
const areaIds =
uniqueIds(
data.venues.map(
function ( row ) {
return row.area_id;
}
)
);
const menuItemIds =
uniqueIds(
data.offerings.map(
function ( row ) {
return row.menu_item_id;
}
)
);
/*
* STEP 3
*/
return Promise.all( [
areaIds.length
? cargoQuery(
'Areas',
'area_id=area_id,' +
'name=area_name,' +
'_pageName=page_name',
'area_id IN (' +
makeInClause(
areaIds
) +
')',
100
)
: Promise.resolve(
[]
),
menuItemIds.length
? cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'stall_id=stall_id,' +
'name=menu_name,' +
'item_category=item_category',
'menu_item_id IN (' +
makeInClause(
menuItemIds
) +
')',
100
)
: Promise.resolve(
[]
)
] ).then(
function ( results ) {
data.areas =
results[ 0 ];
data.menuItems =
results[ 1 ];
return data;
}
);
}
).then(
function ( data ) {
const placementMap =
mapBy(
data.placements,
'placement_id'
);
const stallMap =
mapBy(
data.stalls,
'stall_id'
);
const festivalMap =
mapBy(
data.festivals,
'festival_id'
);
const venueMap =
mapBy(
data.venues,
'venue_id'
);
const areaMap =
mapBy(
data.areas,
'area_id'
);
const menuMap =
mapBy(
data.menuItems,
'menu_item_id'
);
/*
* Offering
* placement単位
*/
const offeringsByPlacement =
{};
data.offerings
.slice()
.sort(
function ( a, b ) {
return (
Number(
a.sort_order || 0
) -
Number(
b.sort_order || 0
)
);
}
)
.forEach(
function ( offering ) {
const placementId =
String(
offering
.placement_id
);
if (
!offeringsByPlacement[
placementId
]
) {
offeringsByPlacement[
placementId
] = [];
}
offeringsByPlacement[
placementId
].push(
offering
);
}
);
/*
* localStorage順を維持
*/
const compareData =
placementIds.map(
function (
placementId
) {
const placement =
placementMap[
placementId
] || null;
if ( !placement ) {
return {
placementId:
placementId,
placement:
null,
stall:
null,
festival:
null,
venue:
null,
area:
null,
menus:
[]
};
}
const stall =
stallMap[
String(
placement.stall_id
)
] || null;
const festival =
festivalMap[
String(
placement.festival_id
)
] || null;
const venue =
venueMap[
String(
placement.venue_id
)
] || null;
let area = null;
if (
venue &&
venue.area_id
) {
area =
areaMap[
String(
venue.area_id
)
] || null;
}
const offerings =
offeringsByPlacement[
placementId
] || [];
/*
* Placementに紐づくメニューを生成
*/
const menus =
offerings.map(
function ( offering ) {
const menu =
menuMap[
String(
offering.menu_item_id
)
] || {};
return {
/*
* どの出店の商品か
*/
placementId:
placementId,
menuName:
menu.menu_name ||
'商品',
category:
menu.item_category ||
'',
/*
* 表示価格
*/
price:
cleanNumber(
offering.price
),
/*
* 比較用価格
*/
priceValue:
toFiniteNumber(
offering.price
),
servingQuantity:
cleanNumber(
offering
.serving_quantity
),
servingUnit:
offering
.serving_unit ||
'',
/*
* 表示用単位価格
*/
unitPrice:
getUnitPrice(
offering
),
/*
* 比較用単位価格
*/
unitPriceValue:
getUnitPriceValue(
offering
),
availability:
formatAvailability(
offering
.availability
),
verification:
formatVerification(
offering
.verification_status
),
isLowestPrice:
false,
isLowestUnitPrice:
false
};
}
);
return {
placementId:
placementId,
placement:
placement,
stall:
stall,
festival:
festival,
venue:
venue,
area:
area,
menus:
menus
};
}
);
/* =====================================
* 最安価格・最安単位価格
*
* 「同じ商品名 + 同じ単位」
* の商品だけを比較する
* ===================================== */
function markBestPrices(
compareData
) {
const allMenus = [];
/* =================================
* 比較文字列を正規化
*
* 例:
* "たこ焼き"
* " たこ焼き "
*
* を同じものとして扱う
* ================================= */
function normalizeCompareText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
/*
* 全角・半角などを可能な範囲で統一
*/
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
/*
* 連続空白を1つにする
*/
text =
text.replace(
/\s+/g,
' '
);
/*
* 英字商品名にも対応
*/
text =
text.toLowerCase();
return text;
}
/* =================================
* 全メニューを集める
* ================================= */
compareData.forEach(
function ( data ) {
if (
!data.menus ||
!Array.isArray(
data.menus
)
) {
return;
}
data.menus.forEach(
function ( menu ) {
/*
* 毎回初期化
*/
menu.isLowestPrice =
false;
menu.isLowestUnitPrice =
false;
/*
* 比較用の商品名
*/
menu.compareMenuName =
normalizeCompareText(
menu.menuName
);
/*
* 比較用単位
*/
menu.compareUnit =
normalizeCompareText(
menu.servingUnit
);
allMenus.push(
menu
);
}
);
}
);
/* =================================
* 商品名+単位ごとのグループ
*
* 例:
*
* たこ焼き + 個
* 焼きそば + パック
* りんご飴 + 本
* ================================= */
const groups = {};
allMenus.forEach(
function ( menu ) {
/*
* 商品名が無ければ比較しない
*/
if (
!menu.compareMenuName
) {
return;
}
/*
* 単位が無ければ比較しない
*
* 「同じ商品名+同じ単位」
* が条件だから
*/
if (
!menu.compareUnit
) {
return;
}
const groupKey =
menu.compareMenuName +
'||' +
menu.compareUnit;
if (
!groups[
groupKey
]
) {
groups[
groupKey
] = [];
}
groups[
groupKey
].push(
menu
);
}
);
/* =================================
* グループごとに判定
* ================================= */
Object.keys(
groups
).forEach(
function ( groupKey ) {
const menus =
groups[
groupKey
];
/* =============================
* 2出店以上あるか確認
*
* 同じ出店内だけの商品比較は
* 「最安」としない
* ============================= */
const placementIds =
[
...new Set(
menus.map(
function ( menu ) {
return String(
menu.placementId
);
}
)
)
];
if (
placementIds.length < 2
) {
return;
}
/* =============================
* 最安価格
*
* 同商品+同単位の
* 販売価格を比較
* ============================= */
const priceCandidates =
menus.filter(
function ( menu ) {
return (
menu.priceValue !==
null &&
Number.isFinite(
menu.priceValue
)
);
}
);
/*
* 価格が登録されている
* 出店が2件以上あるか
*/
const pricePlacementIds =
[
...new Set(
priceCandidates.map(
function ( menu ) {
return String(
menu.placementId
);
}
)
)
];
if (
pricePlacementIds.length >= 2
) {
const lowestPrice =
Math.min.apply(
null,
priceCandidates.map(
function ( menu ) {
return menu
.priceValue;
}
)
);
priceCandidates.forEach(
function ( menu ) {
/*
* 円なので通常整数だが
* 小数にも一応対応
*/
if (
Math.abs(
menu.priceValue -
lowestPrice
) <
0.000001
) {
menu.isLowestPrice =
true;
}
}
);
}
/* =============================
* 最安単位価格
*
* 同商品+同単位で
* price / quantity を比較
* ============================= */
const unitPriceCandidates =
menus.filter(
function ( menu ) {
return (
menu.unitPriceValue !==
null &&
Number.isFinite(
menu.unitPriceValue
)
);
}
);
const unitPricePlacementIds =
[
...new Set(
unitPriceCandidates.map(
function ( menu ) {
return String(
menu.placementId
);
}
)
)
];
if (
unitPricePlacementIds.length >= 2
) {
const lowestUnitPrice =
Math.min.apply(
null,
unitPriceCandidates.map(
function ( menu ) {
return menu
.unitPriceValue;
}
)
);
unitPriceCandidates.forEach(
function ( menu ) {
/*
* 割り算による
* 浮動小数誤差対策
*/
if (
Math.abs(
menu.unitPriceValue -
lowestUnitPrice
) <
0.000001
) {
menu.isLowestUnitPrice =
true;
}
}
);
}
}
);
}
/* =====================================
* 商品別比較サマリー
*
* 同じ商品名 + 同じ単位でグループ化
* ===================================== */
function renderProductGroupSummary(
compareData
) {
const comparePage =
document.getElementById(
'stall-compare-page'
);
if (
!comparePage
) {
return;
}
/* =================================
* 文字列正規化
* ================================= */
function normalizeText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
text =
text.replace(
/\s+/g,
' '
);
return text;
}
/* =================================
* 屋台ページリンクを生成
* ================================= */
function appendStallLinks(
container,
items
) {
const stalls = [];
const seen = {};
items.forEach(
function ( item ) {
/*
* page_nameがある場合は
* page_nameで重複判定
*
* 無い場合は名前で判定
*/
const key =
item.stallPage
? 'page:' +
item.stallPage
: 'name:' +
item.stallName;
if (
seen[
key
]
) {
return;
}
seen[
key
] = true;
stalls.push(
{
name:
item.stallName,
page:
item.stallPage
}
);
}
);
stalls.forEach(
function (
stall,
index
) {
/*
* 2件目以降の区切り
*/
if (
index > 0
) {
container.appendChild(
document.createTextNode(
'・'
)
);
}
/*
* ページが存在する場合
* リンクにする
*/
if (
stall.page
) {
const link =
document.createElement(
'a'
);
link.href =
mw.util.getUrl(
stall.page
);
link.textContent =
stall.name;
link.className =
'stall-product-group-stall-link';
container.appendChild(
link
);
} else {
/*
* page_nameが取得できない場合
* 普通の文字として表示
*/
container.appendChild(
document.createTextNode(
stall.name
)
);
}
}
);
}
/* =================================
* 商品グループ作成
* ================================= */
const groups = {};
compareData.forEach(
function ( data ) {
if (
!data.menus ||
!Array.isArray(
data.menus
)
) {
return;
}
data.menus.forEach(
function ( menu ) {
const menuName =
normalizeText(
menu.menuName
);
const unit =
normalizeText(
menu.servingUnit
);
/*
* 商品名または単位が無いものは
* 商品比較サマリーから除外
*/
if (
!menuName ||
!unit
) {
return;
}
const key =
menuName.toLowerCase() +
'||' +
unit.toLowerCase();
if (
!groups[
key
]
) {
groups[
key
] = {
menuName:
menuName,
unit:
unit,
items:
[]
};
}
groups[
key
].items.push(
{
placementId:
String(
menu.placementId
),
stallName:
(
data.stall &&
data.stall.stall_name
)
? data.stall.stall_name
: '屋台',
/*
* 屋台ページ名
*/
stallPage:
(
data.stall &&
data.stall.page_name
)
? data.stall.page_name
: '',
menu:
menu
}
);
}
);
}
);
const groupKeys =
Object.keys(
groups
);
if (
groupKeys.length === 0
) {
return;
}
/* =================================
* サマリー全体
* ================================= */
const summary =
document.createElement(
'section'
);
summary.className =
'stall-product-group-summary';
const title =
document.createElement(
'h2'
);
title.className =
'stall-product-group-summary-title';
title.textContent =
'商品別比較サマリー';
summary.appendChild(
title
);
/* =================================
* 各商品グループ
* ================================= */
groupKeys.forEach(
function ( key ) {
const group =
groups[
key
];
const items =
group.items;
/*
* 同じPlacementを重複カウントしない
*/
const placementIds =
[
...new Set(
items.map(
function ( item ) {
return item
.placementId;
}
)
)
];
const card =
document.createElement(
'div'
);
card.className =
'stall-product-group-card';
/* =============================
* 商品名
* ============================= */
const heading =
document.createElement(
'h3'
);
heading.className =
'stall-product-group-name';
heading.textContent =
group.menuName +
' / ' +
group.unit;
card.appendChild(
heading
);
/* =============================
* 比較店舗数
* ============================= */
const count =
document.createElement(
'div'
);
count.className =
'stall-product-group-count';
count.textContent =
'比較店舗:' +
placementIds.length +
'店';
card.appendChild(
count
);
/* =============================
* 対象店舗リンク
* ============================= */
const stallList =
document.createElement(
'div'
);
stallList.className =
'stall-product-group-stalls';
const stallListLabel =
document.createElement(
'span'
);
stallListLabel.className =
'stall-product-group-label';
stallListLabel.textContent =
'対象店舗:';
stallList.appendChild(
stallListLabel
);
/*
* 屋台名をリンクとして追加
*/
appendStallLinks(
stallList,
items
);
card.appendChild(
stallList
);
/* =============================
* 1店舗しかない場合
* ============================= */
if (
placementIds.length < 2
) {
const notice =
document.createElement(
'div'
);
notice.className =
'stall-product-group-notice';
notice.textContent =
'比較対象が1店舗のみです。';
card.appendChild(
notice
);
}
/* =============================
* 最安価格の商品
* ============================= */
const lowestPriceItems =
items.filter(
function ( item ) {
return (
item.menu
.isLowestPrice ===
true
);
}
);
if (
lowestPriceItems.length > 0
) {
const lowestPrice =
lowestPriceItems[
0
].menu.priceValue;
const row =
document.createElement(
'div'
);
row.className =
'stall-product-group-best';
const label =
document.createElement(
'span'
);
label.className =
'stall-product-group-label';
label.textContent =
'最安価格:';
const value =
document.createElement(
'strong'
);
value.textContent =
lowestPrice +
'円';
row.appendChild(
label
);
row.appendChild(
value
);
card.appendChild(
row
);
/*
* 最安店舗リンク
*/
const shopRow =
document.createElement(
'div'
);
shopRow.className =
'stall-product-group-shop';
const shopLabel =
document.createElement(
'span'
);
shopLabel.className =
'stall-product-group-label';
shopLabel.textContent =
'最安:';
shopRow.appendChild(
shopLabel
);
/*
* 最安店舗をリンク表示
*/
appendStallLinks(
shopRow,
lowestPriceItems
);
card.appendChild(
shopRow
);
}
/* =============================
* 最安単位価格
* ============================= */
const lowestUnitItems =
items.filter(
function ( item ) {
return (
item.menu
.isLowestUnitPrice ===
true
);
}
);
if (
lowestUnitItems.length > 0
) {
const unitPrice =
lowestUnitItems[
0
].menu.unitPriceValue;
/*
* 小数表示調整
*/
const displayUnitPrice =
Math.round(
unitPrice *
100
) /
100;
const row =
document.createElement(
'div'
);
row.className =
'stall-product-group-best-unit';
const label =
document.createElement(
'span'
);
label.className =
'stall-product-group-label';
label.textContent =
'最安単位価格:';
const value =
document.createElement(
'strong'
);
value.textContent =
displayUnitPrice +
'円/' +
group.unit;
row.appendChild(
label
);
row.appendChild(
value
);
card.appendChild(
row
);
}
summary.appendChild(
card
);
}
);
/*
* 比較表の一番上へ追加
*/
comparePage.insertBefore(
summary,
comparePage.firstChild
);
}
/* =================================
* 最安値を自動判定
* ================================= */
markBestPrices(
compareData
);
renderComparison(
compareData
);
/*
* 詳細比較表を描画した後に
* 商品別サマリーを追加
*/
renderProductGroupSummary(
compareData
);
}
).catch(
function ( error ) {
console.error(
'Placement比較データ取得エラー:',
error
);
renderMessage(
'比較データの取得中にエラーが発生しました。'
);
}
);
} );
$(function () {
const statusLabels = {
active: '出店中・出店予定',
cancelled: '出店中止',
unknown: '未確認'
};
const statusSelect = document.querySelector(
'select[name="FestivalStallPlacement[status]"]'
);
if (statusSelect) {
Array.from(statusSelect.options).forEach(function (option) {
if (statusLabels[option.value]) {
option.textContent = statusLabels[option.value];
}
});
}
const verificationLabels = {
verified: '確認済み',
partially_verified: '一部確認済み',
unverified: '未確認',
outdated: '情報が古い可能性あり'
};
const verificationSelect = document.querySelector(
'select[name="FestivalStallPlacement[verification_status]"]'
);
if (verificationSelect) {
Array.from(verificationSelect.options).forEach(function (option) {
if (verificationLabels[option.value]) {
option.textContent = verificationLabels[option.value];
}
});
}
const yearInput = document.querySelector(
'input[name="FestivalStallPlacement[year]"]'
);
if (yearInput) {
yearInput.inputMode = 'numeric';
yearInput.maxLength = 4;
const validateYear = function () {
const value = yearInput.value.trim();
if (value !== '' && !/^\d{4}$/.test(value)) {
yearInput.setCustomValidity(
'開催年は4桁の数字で入力してください(例:2026)'
);
} else {
yearInput.setCustomValidity('');
}
};
yearInput.addEventListener('input', validateYear);
yearInput.addEventListener('change', validateYear);
yearInput.addEventListener('invalid', validateYear);
validateYear();
}
const positionLabels = {
exact: '位置確認済み',
approximate: 'おおよその位置',
unknown: '位置未確認'
};
const positionSelect = document.querySelector(
'select[name="FestivalStallPlacement[position_status]"]'
);
if (positionSelect) {
Array.from(positionSelect.options).forEach(function (option) {
if (positionLabels[option.value]) {
option.textContent = positionLabels[option.value];
}
});
}
const accuracyInput = document.querySelector(
'input[name="FestivalStallPlacement[position_accuracy_m]"]'
);
if (accuracyInput) {
accuracyInput.inputMode = 'numeric';
const validateAccuracy = function () {
const value = accuracyInput.value.trim();
if (value !== '' && !/^\d+$/.test(value)) {
accuracyInput.setCustomValidity(
'位置精度は0以上の整数で入力してください(例:10)'
);
} else {
accuracyInput.setCustomValidity('');
}
};
accuracyInput.addEventListener('input', validateAccuracy);
accuracyInput.addEventListener('change', validateAccuracy);
accuracyInput.addEventListener('invalid', validateAccuracy);
validateAccuracy();
}
const openingTimeInput = document.querySelector(
'input[name="FestivalStallPlacement[opening_time]"]'
);
const closingTimeInput = document.querySelector(
'input[name="FestivalStallPlacement[closing_time]"]'
);
const timePattern = /^([01]\d|2[0-3]):[0-5]\d$/;
function setupTimeValidation(input, label) {
if (!input) {
return;
}
input.placeholder = '例:10:00';
const validateTime = function () {
const value = input.value.trim();
input.setCustomValidity('');
if (value !== '' && !timePattern.test(value)) {
input.setCustomValidity(
label + 'は24時間表記の HH:MM 形式で入力してください(例:10:00)'
);
}
};
input.addEventListener('input', validateTime);
input.addEventListener('change', validateTime);
input.addEventListener('invalid', validateTime);
validateTime();
}
setupTimeValidation(openingTimeInput, '営業開始時刻');
setupTimeValidation(closingTimeInput, '営業終了時刻');
const latitudeInput = document.querySelector(
'input[name="FestivalStallPlacement[latitude]"]'
);
const longitudeInput = document.querySelector(
'input[name="FestivalStallPlacement[longitude]"]'
);
function setupCoordinateValidation(input, label, min, max) {
if (!input) {
return null;
}
input.inputMode = 'decimal';
const validateCoordinate = function () {
const value = input.value.trim();
input.setCustomValidity('');
/*
* exact または approximate の場合は
* 緯度・経度を必須にする。
*/
if (value === '') {
if (
positionSelect &&
(
positionSelect.value === 'exact' ||
positionSelect.value === 'approximate'
)
) {
input.setCustomValidity(
label +
'は「位置確認済み」または「おおよその位置」を選択した場合は必須です。'
);
return;
}
const otherInput =
input === latitudeInput
? longitudeInput
: latitudeInput;
if (
otherInput &&
otherInput.value.trim() !== ''
) {
input.setCustomValidity(
'緯度と経度は両方入力するか、両方空欄にしてください。'
);
}
return;
}
/*
* 数値形式チェック
*/
if (!/^-?\d+(\.\d+)?$/.test(value)) {
input.setCustomValidity(
label + 'は数値で入力してください。'
);
return;
}
/*
* 範囲チェック
*/
const number = Number(value);
if (number < min || number > max) {
input.setCustomValidity(
label +
'は' +
min +
'〜' +
max +
'の範囲で入力してください。'
);
}
};
input.addEventListener(
'input',
validateCoordinate
);
input.addEventListener(
'change',
validateCoordinate
);
input.addEventListener(
'invalid',
validateCoordinate
);
validateCoordinate();
/*
* position_status変更時に
* 再チェックできるよう関数を返す。
*/
return validateCoordinate;
}
const validateLatitude =
setupCoordinateValidation(
latitudeInput,
'緯度',
20,
46
);
const validateLongitude =
setupCoordinateValidation(
longitudeInput,
'経度',
122,
154
);
/*
* 一方の座標を変更した場合、
* 反対側のペア整合性も再検証する。
*/
if (
latitudeInput &&
validateLongitude
) {
latitudeInput.addEventListener(
'input',
validateLongitude
);
latitudeInput.addEventListener(
'change',
validateLongitude
);
}
if (
longitudeInput &&
validateLatitude
) {
longitudeInput.addEventListener(
'input',
validateLatitude
);
longitudeInput.addEventListener(
'change',
validateLatitude
);
}
/*
* 位置情報の状態を変更した場合、
* 緯度・経度を再検証する。
*/
if (positionSelect) {
positionSelect.addEventListener(
'change',
function () {
if (validateLatitude) {
validateLatitude();
}
if (validateLongitude) {
validateLongitude();
}
}
);
}
const sourceUrlInput = document.querySelector(
'input[name="FestivalStallPlacement[source_url]"]'
);
if (sourceUrlInput) {
sourceUrlInput.inputMode = 'url';
const validateSourceUrl = function () {
const value = sourceUrlInput.value.trim();
sourceUrlInput.setCustomValidity('');
if (value === '') {
return;
}
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
sourceUrlInput.setCustomValidity(
'情報元URLは http:// または https:// で始まるURLを入力してください。'
);
}
} catch (e) {
sourceUrlInput.setCustomValidity(
'情報元URLを正しいURL形式で入力してください。'
);
}
};
sourceUrlInput.addEventListener('input', validateSourceUrl);
sourceUrlInput.addEventListener('change', validateSourceUrl);
sourceUrlInput.addEventListener('invalid', validateSourceUrl);
validateSourceUrl();
}
const sortOrderInput = document.querySelector(
'input[name="FestivalStallPlacement[sort_order]"]'
);
if (sortOrderInput) {
sortOrderInput.inputMode = 'numeric';
const validateSortOrder = function () {
const value = sortOrderInput.value.trim();
sortOrderInput.setCustomValidity('');
if (value !== '' && !/^\d+$/.test(value)) {
sortOrderInput.setCustomValidity(
'表示順は0以上の整数で入力してください(例:1)'
);
}
};
sortOrderInput.addEventListener('input', validateSortOrder);
sortOrderInput.addEventListener('change', validateSortOrder);
sortOrderInput.addEventListener('invalid', validateSortOrder);
validateSortOrder();
}
});
/**
* FestivalStallPlacement - 最終確認日の未来日チェック
*/
(function () {
'use strict';
function setupLastConfirmedValidation() {
const dateInputs = document.querySelectorAll(
'input[name="FestivalStallPlacement[last_confirmed]"]'
);
dateInputs.forEach(function (dateInput) {
if (dateInput.dataset.lastConfirmedValidation === '1') {
return;
}
dateInput.dataset.lastConfirmedValidation = '1';
function getVisibleInput() {
const widget = dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
return widget.querySelector('input[type="text"]');
}
function getErrorElement() {
const widget = dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
let error = widget.parentNode.querySelector(
'.stall-last-confirmed-error'
);
if (!error) {
error = document.createElement('div');
error.className = 'stall-last-confirmed-error';
error.setAttribute('role', 'alert');
error.hidden = true;
widget.insertAdjacentElement('afterend', error);
}
return error;
}
function showError() {
const visibleInput = getVisibleInput();
const error = getErrorElement();
if (!visibleInput || !error) {
return;
}
let message;
if (dateInput.validity.rangeOverflow) {
const maxDate = dateInput.max.replace(/-/g, '/');
message =
'未来の日付は入力できません。' +
maxDate +
'以前の日付を入力してください。';
} else {
message =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}
error.textContent = message;
error.hidden = false;
visibleInput.setAttribute('aria-invalid', 'true');
}
function clearError() {
const visibleInput = getVisibleInput();
const error = getErrorElement();
if (error) {
error.hidden = true;
error.textContent = '';
}
if (visibleInput) {
visibleInput.removeAttribute('aria-invalid');
}
}
/*
* 非表示の date input に対する
* ブラウザ標準エラー表示を止める。
*/
dateInput.addEventListener('invalid', function (event) {
event.preventDefault();
showError();
const visibleInput = getVisibleInput();
if (visibleInput) {
window.setTimeout(function () {
visibleInput.focus();
}, 0);
}
});
/*
* ユーザーが日付を修正したら
* 有効になった時点でエラーを消す。
*/
const form = dateInput.form;
if (form) {
function handleDateChange(event) {
const currentWidget =
dateInput.closest('.oo-ui-widget');
if (
!currentWidget ||
!currentWidget.contains(event.target)
) {
return;
}
window.setTimeout(function () {
if (dateInput.validity.valid) {
clearError();
} else if (
dateInput.validity.rangeOverflow
) {
showError();
}
}, 0);
}
form.addEventListener(
'input',
handleDateChange
);
form.addEventListener(
'change',
handleDateChange
);
/*
* Page Forms のカレンダー選択では
* visible input に blur が発生する。
* blur は通常バブルしないため capture=true。
*/
form.addEventListener(
'blur',
handleDateChange,
true
);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupLastConfirmedValidation
);
} else {
setupLastConfirmedValidation();
}
mw.hook('wikipage.content').add(function () {
setupLastConfirmedValidation();
});
})();
/**
* FestivalStallPlacement
* Cargo既存レコード候補警告 V2
*
* 同じ festival + year + venue + stall があれば
* 警告と既存ページへのリンクを表示する。
* 保存自体は禁止しない。
*/
mw.loader.using([
'mediawiki.api',
'mediawiki.util'
]).then(function () {
'use strict';
const api = new mw.Api();
function setupDuplicateWarning() {
const form = document.getElementById('pfForm');
if (!form) {
return;
}
if (form.dataset.duplicateWarningV2 === '1') {
return;
}
const table = form.querySelector('.formtable');
if (!table) {
return;
}
form.dataset.duplicateWarningV2 = '1';
const warning = document.createElement('div');
warning.className = 'stall-duplicate-warning';
warning.setAttribute('role', 'status');
warning.hidden = true;
/*
* 表の中ではなく、表の直前に置く。
* 警告表示でフォームの列幅を崩さない。
*/
table.insertAdjacentElement('beforebegin', warning);
let timer = null;
let requestId = 0;
function escapeCargo(value) {
return String(value).replace(/'/g, "''");
}
function getField(name) {
return form.querySelector(
'[name="FestivalStallPlacement[' +
name +
']"]'
);
}
function cargoQuery(tableName, fields, where, limit) {
return api.get({
action: 'cargoquery',
tables: tableName,
fields: fields,
where: where,
limit: limit || 50,
format: 'json'
}).then(function (data) {
if (
!data ||
!Array.isArray(data.cargoquery)
) {
return [];
}
return data.cargoquery.map(function (item) {
return item.title || item;
});
});
}
function resolveId(
tableName,
idField,
nameField,
value
) {
if (!value) {
return Promise.resolve(null);
}
if (/^\d+$/.test(value)) {
return Promise.resolve(value);
}
return cargoQuery(
tableName,
idField + '=resolved_id',
nameField +
"='" +
escapeCargo(value) +
"'",
2
).then(function (rows) {
if (rows.length !== 1) {
console.warn(
'IDを一意に取得できません:',
tableName,
value,
rows
);
return null;
}
return String(rows[0].resolved_id);
});
}
function clearWarning() {
warning.hidden = true;
warning.replaceChildren();
}
function showFailure() {
warning.replaceChildren();
const text = document.createElement('div');
text.textContent =
'既存データの確認に失敗しました。' +
'登録はできますが、重複がないかご確認ください。';
warning.appendChild(text);
warning.hidden = false;
}
function showCandidates(rows, venueSpecified) {
warning.replaceChildren();
const positionLabels = {
exact: '位置確認済み',
approximate: 'おおよその位置',
unknown: '位置未確認'
};
const verificationLabels = {
verified: '確認済み',
partially_verified: '一部確認済み',
unverified: '未確認',
outdated: '情報が古い可能性あり'
};
const statusLabels = {
active: '出店中・出店予定',
cancelled: '出店中止',
unknown: '未確認'
};
function displayValue(value, fallback) {
if (
value === undefined ||
value === null ||
String(value).trim() === ''
) {
return fallback || '未確認';
}
return String(value);
}
function addDetail(container, label, value) {
const row = document.createElement('div');
row.className =
'stall-duplicate-candidate-detail';
const labelElement =
document.createElement('span');
labelElement.className =
'stall-duplicate-candidate-label';
labelElement.textContent = label;
const valueElement =
document.createElement('span');
valueElement.className =
'stall-duplicate-candidate-value';
valueElement.textContent = value;
row.appendChild(labelElement);
row.appendChild(valueElement);
container.appendChild(row);
}
const title = document.createElement('strong');
title.className =
'stall-duplicate-warning-title';
title.textContent =
venueSpecified
? (
'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
rows.length +
'件あります。'
)
: (
'⚠ 同じ祭り・開催年・屋台の既存データが' +
rows.length +
'件あります。'
);
warning.appendChild(title);
const description =
document.createElement('p');
description.className =
'stall-duplicate-warning-description';
description.textContent =
venueSpecified
? (
'出店場所が異なる場合は新規登録して構いません。' +
'下の既存データと同じ場所ではないか確認してください。'
)
: (
'会場未指定のため、会場を問わず候補を確認しています。' +
'出店場所が異なる場合は新規登録して構いません。' +
'下の既存データと同じ場所ではないか確認してください。'
);
warning.appendChild(description);
const list = document.createElement('div');
list.className =
'stall-duplicate-candidate-list';
/*
* placement_id順に並べる
*/
rows.sort(function (a, b) {
return (
Number(a.placement_id) -
Number(b.placement_id)
);
});
rows.forEach(function (row) {
const card =
document.createElement('div');
card.className =
'stall-duplicate-candidate';
/*
* カード見出し
*/
const header =
document.createElement('div');
header.className =
'stall-duplicate-candidate-header';
const heading =
document.createElement('strong');
heading.textContent =
'既存の出店情報';
header.appendChild(heading);
card.appendChild(header);
/*
* 出店場所
*/
addDetail(
card,
'出店場所',
displayValue(
row.location_note,
'場所メモなし'
)
);
/*
* 位置状態
*/
addDetail(
card,
'位置状態',
positionLabels[
row.position_status
] ||
displayValue(
row.position_status,
'位置未確認'
)
);
/*
* 緯度・経度
*/
let coordinates =
'位置情報なし';
if (
row.latitude !== undefined &&
row.latitude !== null &&
String(row.latitude).trim() !== '' &&
row.longitude !== undefined &&
row.longitude !== null &&
String(row.longitude).trim() !== ''
) {
coordinates =
String(row.latitude) +
', ' +
String(row.longitude);
}
addDetail(
card,
'緯度・経度',
coordinates
);
/*
* 位置精度
*/
let accuracy = '未確認';
if (
row.position_accuracy_m !== undefined &&
row.position_accuracy_m !== null &&
String(row.position_accuracy_m).trim() !== ''
) {
accuracy =
String(row.position_accuracy_m) +
' m';
}
addDetail(
card,
'位置精度',
accuracy
);
/*
* 出店状態
*/
addDetail(
card,
'出店状態',
statusLabels[
row.status
] ||
displayValue(
row.status,
'未確認'
)
);
/*
* 最終確認日
*/
let lastConfirmed = '未確認';
if (
row.last_confirmed !== undefined &&
row.last_confirmed !== null &&
String(row.last_confirmed).trim() !== ''
) {
lastConfirmed =
String(row.last_confirmed)
.replace(/-/g, '/');
}
addDetail(
card,
'最終確認日',
lastConfirmed
);
/*
* 確認状態
*/
addDetail(
card,
'確認状態',
verificationLabels[
row.verification_status
] ||
displayValue(
row.verification_status,
'未確認'
)
);
/*
* 既存ページへのリンク
*/
const actions =
document.createElement('div');
actions.className =
'stall-duplicate-candidate-actions';
const link =
document.createElement('a');
link.href =
mw.util.getUrl(row.page_name);
link.target = '_blank';
link.rel = 'noopener';
link.textContent =
'既存データを確認';
actions.appendChild(link);
card.appendChild(actions);
list.appendChild(card);
});
warning.appendChild(list);
const footer =
document.createElement('div');
footer.className =
'stall-duplicate-warning-footer';
footer.textContent =
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';
warning.appendChild(footer);
warning.hidden = false;
}
function checkDuplicates() {
const currentRequest = ++requestId;
/*
* 毎回現在のinput/selectを取得する。
* Page Formsが要素を作り直しても対応できる。
*/
const stall = getField('stall_id');
const festival = getField('festival_id');
const venue = getField('venue_id');
const year = getField('year');
if (
!stall ||
!festival ||
!venue ||
!year
) {
clearWarning();
return;
}
const stallValue = stall.value.trim();
const festivalValue = festival.value.trim();
const venueValue = venue.value.trim();
const yearValue = year.value.trim();
if (
!stallValue ||
!festivalValue ||
!/^\d{4}$/.test(yearValue)
) {
clearWarning();
return;
}
/*
* async / await は使わず、
* Promise の then() で処理する。
*/
Promise.all([
resolveId(
'Stalls',
'stall_id',
'name',
stallValue
),
resolveId(
'Festivals',
'festival_id',
'name',
festivalValue
),
venueValue !== ''
? resolveId(
'Venues',
'venue_id',
'_pageName',
venueValue
)
: Promise.resolve(null)
])
.then(function (ids) {
if (currentRequest !== requestId) {
return null;
}
if (
!ids[0] ||
!ids[1] ||
(
venueValue !== '' &&
!ids[2]
)
) {
clearWarning();
return null;
}
let where =
'festival_id=' +
ids[1] +
' AND year=' +
yearValue +
' AND stall_id=' +
ids[0];
if (ids[2]) {
where +=
' AND venue_id=' +
ids[2];
}
return cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'location_note=location_note,' +
'latitude=latitude,' +
'longitude=longitude,' +
'position_status=position_status,' +
'position_accuracy_m=position_accuracy_m,' +
'status=status,' +
'verification_status=verification_status,' +
'last_confirmed=last_confirmed,' +
'_pageName=page_name',
where,
50
).then(function (rows) {
if (currentRequest !== requestId) {
return;
}
console.log(
'FestivalStallPlacement候補:',
where,
rows
);
if (rows.length === 0) {
clearWarning();
return;
}
/*
* 今回は警告のみ。
* 同条件の既存データをすべて表示する。
*/
const rawPageName =
String(
mw.config.get('wgPageName') ||
''
);
const formEditMarker =
'/FestivalStallPlacement/';
const markerIndex =
rawPageName.indexOf(
formEditMarker
);
const currentPlacementPage =
markerIndex >= 0
? rawPageName
.slice(
markerIndex +
formEditMarker.length
)
.replace(/_/g, ' ')
.trim()
: '';
const filteredRows =
currentPlacementPage
? rows.filter(function (row) {
return (
String(
row.page_name ||
''
)
.replace(/_/g, ' ')
.trim() !==
currentPlacementPage
);
})
: rows;
if (filteredRows.length === 0) {
clearWarning();
return;
}
showCandidates(
filteredRows,
venueValue !== ''
);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);
showFailure();
});
}
function scheduleCheck() {
window.clearTimeout(timer);
timer = window.setTimeout(
checkDuplicates,
300
);
}
/*
* form自身へイベントを設定する。
* dropdownが後から置き換わっても拾える。
*/
form.addEventListener('change', function (event) {
const name = event.target.name || '';
if (
name ===
'FestivalStallPlacement[stall_id]' ||
name ===
'FestivalStallPlacement[festival_id]' ||
name ===
'FestivalStallPlacement[venue_id]' ||
name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});
form.addEventListener('input', function (event) {
if (
event.target.name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});
scheduleCheck();
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupDuplicateWarning
);
} else {
setupDuplicateWarning();
}
mw.hook('pf.formSetupAfter').add(
setupDuplicateWarning
);
});
/*
* FestivalStallMenuOffering
* 入力検証・日本語表示
*/
(function () {
'use strict';
var FORM_ID = 'pfForm';
var availabilityLabels = {
available: '販売中',
unknown: '未確認'
};
var verificationLabels = {
verified: '確認済み',
partially_verified: '一部確認済み',
unverified: '未確認',
outdated: '情報が古い可能性あり'
};
function isOfferingField(element) {
return !!(
element &&
element.name &&
element.name.indexOf(
'FestivalStallMenuOffering['
) === 0
);
}
function isTemplateField(element) {
return !!(
element &&
element.name &&
element.name.indexOf('[num]') !== -1
);
}
function fieldNameEndsWith(element, suffix) {
return !!(
element &&
element.name &&
element.name.slice(-suffix.length) === suffix
);
}
function localizeSelect(select, labels) {
if (!select) {
return;
}
Array.from(select.options).forEach(
function (option) {
if (
Object.prototype.hasOwnProperty.call(
labels,
option.value
) &&
option.textContent !==
labels[option.value]
) {
option.textContent =
labels[option.value];
}
}
);
}
function validatePrice(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (
value !== '' &&
!/^\d+$/.test(value)
) {
input.setCustomValidity(
'価格は0以上の整数で入力してください(例:600)'
);
}
}
function validateServingQuantity(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (value === '') {
return;
}
if (
!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
) {
input.setCustomValidity(
'提供数量は0以上の数値で入力してください(例:8、1、0.5)'
);
}
}
function validateLimitedQuantity(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (
value !== '' &&
!/^\d+$/.test(value)
) {
input.setCustomValidity(
'限定数量は0以上の整数で入力してください(例:100)'
);
}
}
function validateSortOrder(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (
value !== '' &&
!/^\d+$/.test(value)
) {
input.setCustomValidity(
'表示順は0以上の整数で入力してください(例:1)'
);
}
}
function validateSourceUrl(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (value === '') {
return;
}
try {
var url = new URL(value);
if (
url.protocol !== 'http:' &&
url.protocol !== 'https:'
) {
input.setCustomValidity(
'情報元URLは http:// または https:// で始まるURLを入力してください。'
);
}
} catch (e) {
input.setCustomValidity(
'情報元URLを正しいURL形式で入力してください。'
);
}
}
function validateLastConfirmed(input) {
var value = input.value;
var max = input.max;
input.setCustomValidity('');
if (
value !== '' &&
max !== '' &&
value > max
) {
input.setCustomValidity(
'未来の日付は入力できません。' +
max.replace(/-/g, '/') +
'以前の日付を入力してください。'
);
}
}
function getVisibleDateInput(dateInput) {
var widget =
dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
return widget.querySelector(
'input[type="text"]'
);
}
function getDateErrorElement(dateInput) {
var widget =
dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
var next =
widget.nextElementSibling;
if (
next &&
next.classList.contains(
'stall-offering-last-confirmed-error'
)
) {
return next;
}
var error =
document.createElement('div');
/*
* 既存の最終確認日エラー用CSSも利用する。
*/
error.className =
'stall-last-confirmed-error ' +
'stall-offering-last-confirmed-error';
error.setAttribute(
'role',
'alert'
);
error.hidden = true;
widget.insertAdjacentElement(
'afterend',
error
);
return error;
}
function showDateError(dateInput) {
var visibleInput =
getVisibleDateInput(dateInput);
var error =
getDateErrorElement(dateInput);
if (!error) {
return;
}
var maxDate =
dateInput.max
? dateInput.max.replace(/-/g, '/')
: '';
if (
dateInput.validity.rangeOverflow ||
(
dateInput.value &&
dateInput.max &&
dateInput.value > dateInput.max
)
) {
error.textContent =
'未来の日付は入力できません。' +
maxDate +
'以前の日付を入力してください。';
} else {
error.textContent =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}
error.hidden = false;
if (visibleInput) {
visibleInput.setAttribute(
'aria-invalid',
'true'
);
}
}
function clearDateError(dateInput) {
var visibleInput =
getVisibleDateInput(dateInput);
var widget =
dateInput.closest('.oo-ui-widget');
var error = null;
if (
widget &&
widget.nextElementSibling &&
widget.nextElementSibling.classList.contains(
'stall-offering-last-confirmed-error'
)
) {
error =
widget.nextElementSibling;
}
if (error) {
error.hidden = true;
error.textContent = '';
}
if (visibleInput) {
visibleInput.removeAttribute(
'aria-invalid'
);
}
}
function getLimitedQuantityInput(
checkbox,
form
) {
if (!checkbox || !checkbox.name) {
return null;
}
var quantityName =
checkbox.name.replace(
/\[limited\]\[value\]$/,
'[limited_quantity]'
);
return Array.from(
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]'
)
).find(
function (input) {
return input.name === quantityName;
}
) || null;
}
function updateLimitedState(
checkbox,
form,
clearWhenOff
) {
var quantityInput =
getLimitedQuantityInput(
checkbox,
form
);
if (!quantityInput) {
return;
}
if (checkbox.checked) {
quantityInput.disabled = false;
quantityInput.removeAttribute(
'aria-disabled'
);
} else {
if (clearWhenOff) {
quantityInput.value = '';
}
quantityInput.setCustomValidity('');
quantityInput.disabled = true;
quantityInput.setAttribute(
'aria-disabled',
'true'
);
}
}
function validateField(element) {
if (
!isOfferingField(element) ||
isTemplateField(element)
) {
return;
}
if (
fieldNameEndsWith(
element,
'[price]'
)
) {
validatePrice(element);
return;
}
if (
fieldNameEndsWith(
element,
'[serving_quantity]'
)
) {
validateServingQuantity(element);
return;
}
if (
fieldNameEndsWith(
element,
'[limited_quantity]'
)
) {
validateLimitedQuantity(element);
return;
}
if (
fieldNameEndsWith(
element,
'[sort_order]'
)
) {
validateSortOrder(element);
return;
}
if (
fieldNameEndsWith(
element,
'[source_url]'
)
) {
validateSourceUrl(element);
return;
}
if (
fieldNameEndsWith(
element,
'[last_confirmed]'
)
) {
validateLastConfirmed(element);
if (element.validity.valid) {
clearDateError(element);
}
return;
}
}
function initializeFields(form) {
/*
* 販売状態を日本語化。
* [num]も変更しておくことで、
* 後から追加されるmultipleにも反映される。
*/
form.querySelectorAll(
'select[name^="FestivalStallMenuOffering["]' +
'[name$="[availability]"]'
).forEach(
function (select) {
localizeSelect(
select,
availabilityLabels
);
}
);
/*
* 確認状態を日本語化。
*/
form.querySelectorAll(
'select[name^="FestivalStallMenuOffering["]' +
'[name$="[verification_status]"]'
).forEach(
function (select) {
localizeSelect(
select,
verificationLabels
);
}
);
/*
* 数値入力向けキーボード。
*/
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[price]"],' +
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[limited_quantity]"],' +
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[sort_order]"]'
).forEach(
function (input) {
input.inputMode = 'numeric';
}
);
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[serving_quantity]"]'
).forEach(
function (input) {
input.inputMode = 'decimal';
}
);
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[source_url]"]'
).forEach(
function (input) {
input.inputMode = 'url';
}
);
/*
* 限定数量欄のON/OFF。
*/
form.querySelectorAll(
'input[type="checkbox"]' +
'[name^="FestivalStallMenuOffering["]' +
'[name$="[limited][value]"]'
).forEach(
function (checkbox) {
updateLimitedState(
checkbox,
form,
false
);
}
);
/*
* 現在値を一度検証。
* [num]は除外。
*/
form.querySelectorAll(
'[name^="FestivalStallMenuOffering["]'
).forEach(
function (element) {
validateField(element);
}
);
}
function setupOfferingValidation() {
var form =
document.getElementById(
FORM_ID
);
if (!form) {
return;
}
/*
* wikipage.content 等で再度呼ばれても
* イベントを二重登録しない。
*/
if (
form.dataset
.offeringValidationInitialized ===
'1'
) {
initializeFields(form);
return;
}
form.dataset
.offeringValidationInitialized =
'1';
/*
* multipleで後から追加された項目にも効くよう
* form側でイベント委譲。
*/
form.addEventListener(
'input',
function (event) {
validateField(
event.target
);
}
);
form.addEventListener(
'change',
function (event) {
var target =
event.target;
if (!isOfferingField(target)) {
return;
}
if (
target.type === 'checkbox' &&
fieldNameEndsWith(
target,
'[limited][value]'
)
) {
updateLimitedState(
target,
form,
true
);
}
validateField(target);
}
);
/*
* Page Forms のカレンダー選択では、
* visible input に blur が発生する場合がある。
* 対応する非表示 date input を取得して再検証する。
*/
form.addEventListener(
'blur',
function (event) {
var target = event.target;
if (
!target ||
typeof target.closest !== 'function'
) {
return;
}
var widget =
target.closest('.oo-ui-widget');
if (!widget) {
return;
}
var dateInput =
widget.querySelector(
'input[type="date"]' +
'[name^="FestivalStallMenuOffering["]' +
'[name$="[last_confirmed]"]'
);
if (
!dateInput ||
isTemplateField(dateInput)
) {
return;
}
window.setTimeout(
function () {
validateField(dateInput);
if (dateInput.validity.valid) {
clearDateError(dateInput);
} else {
showDateError(dateInput);
}
},
0
);
},
true
);
/*
* invalidイベントは通常bubbleしないため
* capture=trueで取得する。
*/
form.addEventListener(
'invalid',
function (event) {
var target =
event.target;
if (
!isOfferingField(target) ||
isTemplateField(target)
) {
return;
}
validateField(target);
if (
fieldNameEndsWith(
target,
'[last_confirmed]'
)
) {
event.preventDefault();
showDateError(target);
var visibleInput =
getVisibleDateInput(
target
);
if (visibleInput) {
window.setTimeout(
function () {
visibleInput.focus();
},
0
);
}
}
},
true
);
/*
* 「販売商品を追加」でDOMが増えた場合の初期化。
*/
var mutationTimer = null;
var observer =
new MutationObserver(
function () {
window.clearTimeout(
mutationTimer
);
mutationTimer =
window.setTimeout(
function () {
initializeFields(
form
);
},
100
);
}
);
observer.observe(
form,
{
childList: true,
subtree: true
}
);
initializeFields(form);
}
if (
document.readyState ===
'loading'
) {
document.addEventListener(
'DOMContentLoaded',
setupOfferingValidation
);
} else {
setupOfferingValidation();
}
mw.hook(
'wikipage.content'
).add(
setupOfferingValidation
);
mw.hook(
'pf.formSetupAfter'
).add(
setupOfferingValidation
);
})();
mw.loader.using('mediawiki.api').then(function () {
'use strict';
if (window.__festivalStallMenuFilterInitialized) {
return;
}
window.__festivalStallMenuFilterInitialized = true;
const STALL_SELECTOR =
'select[name="FestivalStallPlacement[stall_id]"]';
const MENU_SELECTOR =
'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
const TEMPLATE_MENU_SELECTOR =
'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
const api = new mw.Api();
let requestSerial = 0;
let observerTimer = null;
let applying = false;
const menuCache = {};
/*
* FestivalStallPlacement フォーム以外では
* この連動機能を起動しない。
*/
const stallSelect =
document.querySelector(STALL_SELECTOR);
if (!stallSelect) {
return;
}
/*
* Page Formsの雛形が持つ全商品optionを最初に保存
*/
const templateSelect =
document.querySelector(TEMPLATE_MENU_SELECTOR);
if (!templateSelect) {
console.error(
'販売商品の雛形SELECTが見つかりません。'
);
return;
}
const masterOptions =
[...templateSelect.options].map(
function (option) {
return option.cloneNode(true);
}
);
function cargoQuote(value) {
return "'" + String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'") + "'";
}
function cargoRows(res) {
return (res.cargoquery || []).map(
function (row) {
return row.title || {};
}
);
}
function getRealMenuSelects() {
return [
...document.querySelectorAll(
MENU_SELECTOR
)
].filter(function (select) {
return !select.name.includes('[num]');
});
}
function resolveStallId(stallName) {
return api.get({
action: 'cargoquery',
format: 'json',
tables: 'Stalls',
fields:
'stall_id=stall_id,' +
'name=name',
where:
'name=' +
cargoQuote(stallName),
limit: 20
}).then(function (res) {
const rows =
cargoRows(res);
if (rows.length === 1) {
return rows[0].stall_id;
}
/*
* 同名表示が
* 名前 (ID)
* になっている場合
*/
const match =
String(stallName)
.match(/\((\d+)\)$/);
if (!match) {
throw new Error(
'屋台を1件に特定できません: ' +
stallName
);
}
return match[1];
});
}
function loadMenus(stallId) {
const key =
String(stallId);
if (menuCache[key]) {
return Promise.resolve(
menuCache[key]
);
}
return api.get({
action: 'cargoquery',
format: 'json',
tables: 'StallMenuItems',
fields:
'menu_item_id=menu_item_id,' +
'stall_id=stall_id,' +
'name=name,' +
'status=status',
where:
'stall_id=' +
Number(stallId) +
" AND status='active'",
order_by:
'menu_item_id',
limit: 100
}).then(function (res) {
const rows =
cargoRows(res);
menuCache[key] =
rows;
return rows;
});
}
function optionBelongsToMenu(
option,
menu
) {
const name =
String(menu.name || '');
const id =
String(
menu.menu_item_id || ''
);
const value =
String(option.value || '');
const text =
String(
option.textContent || ''
);
/*
* 商品名が一意
*/
if (
value === name ||
text === name
) {
return true;
}
/*
* Page Formsによる
* 同名商品の識別表示
*
* たこ焼き (1)
* たこ焼き (3)
*/
const mapped =
name + ' (' + id + ')';
return (
value === mapped ||
text === mapped
);
}
function makeOptions(menus) {
const options = [];
/*
* 空欄
*/
const blank =
masterOptions.find(
function (option) {
return (
option.value === ''
);
}
);
if (blank) {
options.push(
blank.cloneNode(true)
);
} else {
options.push(
new Option('', '')
);
}
menus.forEach(
function (menu) {
const option =
masterOptions.find(
function (candidate) {
return optionBelongsToMenu(
candidate,
menu
);
}
);
if (option) {
options.push(
option.cloneNode(true)
);
} else {
console.warn(
'Page Formsのoptionを特定できません:',
menu
);
}
}
);
return options;
}
function optionSignature(select) {
return [...select.options]
.map(function (option) {
return (
option.value +
'::' +
option.textContent
);
})
.join('||');
}
function filterMenuSelects(
menus,
clearSelection
) {
const desiredTemplate =
makeOptions(menus);
const desiredSignature =
desiredTemplate
.map(function (option) {
return (
option.value +
'::' +
option.textContent
);
})
.join('||');
applying = true;
getRealMenuSelects().forEach(
function (select) {
const previousValue =
select.value;
/*
* すでに正しい候補なら
* DOMを触らない
*/
if (
optionSignature(select) ===
desiredSignature
) {
if (clearSelection &&
select.value !== '') {
select.value = '';
if (window.jQuery) {
jQuery(select)
.trigger('change');
}
}
return;
}
const newOptions =
desiredTemplate.map(
function (option) {
return option
.cloneNode(true);
}
);
select.replaceChildren(
...newOptions
);
if (!clearSelection) {
const exists =
[...select.options]
.some(
function (option) {
return (
option.value ===
previousValue
);
}
);
if (exists) {
select.value =
previousValue;
}
}
if (clearSelection) {
select.value = '';
}
if (window.jQuery) {
jQuery(select)
.trigger('change');
}
}
);
/*
* MutationObserverに
* 自分自身の変更を拾わせない
*/
setTimeout(
function () {
applying = false;
},
0
);
}
function refreshMenus(
clearSelection
) {
const stall =
document.querySelector(
STALL_SELECTOR
);
if (!stall) {
return;
}
if (!stall.value) {
/*
* 屋台が未選択なら、
* 進行中の古い非同期処理を無効化し、
* 商品候補を空欄だけに戻す。
*/
++requestSerial;
filterMenuSelects(
[],
true
);
return;
}
const serial =
++requestSerial;
const stallName =
stall.value;
resolveStallId(
stallName
)
.then(function (stallId) {
if (
serial !==
requestSerial
) {
return null;
}
console.log(
'[屋台→商品V2]',
stallName,
'→ stall_id=' +
stallId
);
return loadMenus(
stallId
);
})
.then(function (menus) {
if (
!menus ||
serial !==
requestSerial
) {
return;
}
console.log(
'[販売商品候補V2]',
menus
);
filterMenuSelects(
menus,
clearSelection
);
})
.catch(function (err) {
console.error(
'[屋台→商品V2] エラー:',
err
);
});
}
/*
* Page Formsによる
* option再生成を検出
*/
function mutationTouchesMenus(
mutation
) {
const target =
mutation.target;
if (
target.nodeType === 1 &&
target.matches &&
target.matches(MENU_SELECTOR)
) {
return true;
}
for (
const node of
mutation.addedNodes
) {
if (
node.nodeType !== 1
) {
continue;
}
if (
node.matches &&
node.matches(MENU_SELECTOR)
) {
return true;
}
if (
node.querySelector &&
node.querySelector(
MENU_SELECTOR
)
) {
return true;
}
/*
* SELECTの中にOPTIONが追加された
*/
if (
node.tagName === 'OPTION' &&
node.parentElement &&
node.parentElement.matches &&
node.parentElement.matches(
MENU_SELECTOR
)
) {
return true;
}
}
return false;
}
const observer =
new MutationObserver(
function (mutations) {
if (applying) {
return;
}
const touched =
mutations.some(
mutationTouchesMenus
);
if (!touched) {
return;
}
clearTimeout(
observerTimer
);
/*
* Page Formsの再初期化が
* 完了してから実行
*/
observerTimer =
setTimeout(
function () {
refreshMenus(false);
},
250
);
}
);
const form =
document.getElementById(
'pfForm'
) || document.body;
observer.observe(
form,
{
childList: true,
subtree: true
}
);
/*
* 屋台変更
*/
const stall =
document.querySelector(
STALL_SELECTOR
);
function onStallChange() {
refreshMenus(true);
}
stall.addEventListener(
'change',
onStallChange
);
/*
* 初期表示
*/
refreshMenus(false);
console.log(
'屋台→販売商品連動を初期化しました。'
);
});
/*
* FestivalStallMenuOffering
* 同一Placement内の商品重複警告
*
* 保存は禁止しない。
*/
(function () {
'use strict';
const MENU_SELECTOR =
'select[name^="FestivalStallMenuOffering["]' +
'[name$="[menu_item_id]"]';
function setupOfferingDuplicateWarning() {
const form =
document.getElementById('pfForm');
if (!form) {
return;
}
/*
* FestivalStallPlacementフォームだけを対象にする。
*/
if (
!form.querySelector(
'[name="FestivalStallPlacement[stall_id]"]'
)
) {
return;
}
function getMenuSelects() {
return [
...form.querySelectorAll(
MENU_SELECTOR
)
].filter(function (select) {
return !select.name.includes('[num]');
});
}
function getWarning() {
let warning =
form.querySelector(
'.stall-offering-duplicate-warning'
);
if (warning) {
return warning;
}
const firstSelect =
getMenuSelects()[0];
if (!firstSelect) {
return null;
}
warning =
document.createElement('div');
warning.className =
'stall-offering-duplicate-warning';
warning.setAttribute(
'role',
'status'
);
warning.hidden = true;
warning.style.marginTop = '8px';
warning.style.padding = '10px';
warning.style.border =
'1px solid #a2a9b1';
warning.style.borderRadius = '4px';
/*
* 警告は個別の商品行ではなく、
* 販売商品multiple全体の上部に表示する。
*/
const wrapper =
firstSelect.closest(
'.multipleTemplateWrapper'
);
const list =
wrapper
? wrapper.querySelector(
'.multipleTemplateList'
)
: null;
if (list) {
list.insertAdjacentElement(
'beforebegin',
warning
);
} else {
const container =
firstSelect.closest('fieldset') ||
firstSelect.closest('td') ||
firstSelect.parentNode;
container.insertBefore(
warning,
container.firstChild
);
}
return warning;
}
function clearWarning() {
const warning =
form.querySelector(
'.stall-offering-duplicate-warning'
);
if (!warning) {
return;
}
warning.hidden = true;
warning.textContent = '';
}
function checkDuplicates() {
const selects =
getMenuSelects();
const counts = {};
selects.forEach(function (select) {
const value =
String(
select.value || ''
).trim();
if (!value) {
return;
}
counts[value] =
(counts[value] || 0) + 1;
});
const duplicates =
Object.keys(counts).filter(
function (value) {
return counts[value] > 1;
}
);
if (duplicates.length === 0) {
clearWarning();
return;
}
const warning =
getWarning();
if (!warning) {
return;
}
warning.textContent = '';
const title =
document.createElement('strong');
title.textContent =
'同じ販売商品が複数回選択されています。';
warning.appendChild(title);
const detail =
document.createElement('div');
detail.textContent =
duplicates.join('、') +
' が重複しています。' +
'重複登録でないか確認してください。' +
'保存自体は禁止しません。';
warning.appendChild(detail);
warning.hidden = false;
}
/*
* multipleで後から追加された行にも対応。
*/
if (
form.dataset
.offeringDuplicateWarning !== '1'
) {
form.dataset
.offeringDuplicateWarning = '1';
/*
* Page Forms / Select2 は
* jQueryのchangeを使う場合があるため、
* jQuery側でイベント委譲する。
*/
if (window.jQuery) {
jQuery(form).on(
'change.offeringDuplicateWarning',
MENU_SELECTOR,
function () {
window.setTimeout(
checkDuplicates,
0
);
}
);
} else {
/*
* jQueryが無い場合のフォールバック。
*/
form.addEventListener(
'change',
function (event) {
if (
event.target.matches &&
event.target.matches(
MENU_SELECTOR
)
) {
window.setTimeout(
checkDuplicates,
0
);
}
}
);
}
const observer =
new MutationObserver(
function () {
window.setTimeout(
checkDuplicates,
0
);
}
);
observer.observe(
form,
{
childList: true,
subtree: true
}
);
}
checkDuplicates();
}
if (
document.readyState === 'loading'
) {
document.addEventListener(
'DOMContentLoaded',
setupOfferingDuplicateWarning
);
} else {
setupOfferingDuplicateWarning();
}
mw.hook(
'wikipage.content'
).add(
setupOfferingDuplicateWarning
);
})();
/*
* StallMenuItem
* 入力検証・状態日本語化
*/
(function () {
'use strict';
function setupStallMenuItemValidation() {
const form = document.getElementById('pfForm');
if (!form) {
return;
}
/*
* StallMenuItemフォーム以外では何もしない。
*/
const nameInput = form.querySelector(
'input[name="StallMenuItem[name]"]'
);
if (!nameInput) {
return;
}
/*
* 二重初期化防止
*/
if (
form.dataset.stallMenuItemValidationInitialized === '1'
) {
return;
}
form.dataset.stallMenuItemValidationInitialized = '1';
/*
* =====================================
* 状態を日本語表示
* =====================================
*/
const statusLabels = {
active: '取扱中',
inactive: '一時停止',
discontinued: '取扱終了',
unknown: '未確認'
};
const statusSelect = form.querySelector(
'select[name="StallMenuItem[status]"]'
);
if (statusSelect) {
Array.from(statusSelect.options).forEach(
function (option) {
if (statusLabels[option.value]) {
option.textContent =
statusLabels[option.value];
}
}
);
}
console.log(
'商品マスター入力チェックを初期化しました。'
);
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupStallMenuItemValidation
);
} else {
setupStallMenuItemValidation();
}
mw.hook('wikipage.content').add(
setupStallMenuItemValidation
);
})();
/*
* StallMenuItem
* 同一屋台 + 同一商品名の重複警告
*
* 保存は禁止しない。
*/
mw.loader.using([
'mediawiki.api',
'mediawiki.util'
]).then(function () {
'use strict';
const api = new mw.Api();
function cargoQuote(value) {
return "'" + String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'") + "'";
}
function cargoRows(response) {
return (response.cargoquery || []).map(
function (row) {
return row.title || row;
}
);
}
function cargoQuery(
tables,
fields,
where,
limit
) {
return api.get({
action: 'cargoquery',
format: 'json',
tables: tables,
fields: fields,
where: where,
limit: String(limit || 20)
}).then(
function (response) {
return cargoRows(response);
}
);
}
function normalizePageName(value) {
return String(value || '')
.replace(/_/g, ' ')
.trim();
}
function setupStallMenuItemDuplicateWarning() {
const form =
document.getElementById('pfForm');
if (!form) {
return;
}
/*
* StallMenuItemフォームだけを対象にする。
*/
const stallSelect = form.querySelector(
'select[name="StallMenuItem[stall_id]"]'
);
const nameInput = form.querySelector(
'input[name="StallMenuItem[name]"]'
);
if (!stallSelect || !nameInput) {
return;
}
/*
* 二重初期化防止
*/
if (
form.dataset
.stallMenuItemDuplicateWarning ===
'1'
) {
return;
}
form.dataset
.stallMenuItemDuplicateWarning =
'1';
/*
* 警告表示欄
*/
const warning =
document.createElement('div');
warning.className =
'stall-menu-item-duplicate-warning';
warning.setAttribute(
'role',
'status'
);
warning.hidden = true;
warning.style.marginTop = '8px';
warning.style.padding = '10px';
warning.style.border = '1px solid #a2a9b1';
warning.style.borderRadius = '4px';
const container =
nameInput.closest('td') ||
nameInput.parentNode;
container.appendChild(warning);
let timer = null;
let requestSerial = 0;
/*
* Page Formsのmappingでは
* SELECT.valueが屋台名になる場合があるため、
* Cargoからstall_idを解決する。
*/
function resolveStallId() {
const rawValue =
String(
stallSelect.value || ''
).trim();
if (!rawValue) {
return Promise.resolve('');
}
/*
* 数値ならそのまま使用。
*/
if (/^\d+$/.test(rawValue)) {
return Promise.resolve(
rawValue
);
}
const selectedOption =
stallSelect.options[
stallSelect.selectedIndex
];
const selectedText =
selectedOption
? selectedOption.textContent.trim()
: '';
const names = [];
if (rawValue) {
names.push(rawValue);
}
if (
selectedText &&
names.indexOf(selectedText) === -1
) {
names.push(selectedText);
}
if (names.length === 0) {
return Promise.resolve('');
}
const where = names.map(
function (name) {
return (
'name=' +
cargoQuote(name)
);
}
).join(' OR ');
return cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name',
where,
10
).then(
function (rows) {
if (!rows.length) {
return '';
}
return String(
rows[0].stall_id || ''
);
}
);
}
function clearWarning() {
warning.hidden = true;
warning.textContent = '';
}
function showWarning(rows) {
warning.textContent = '';
const title =
document.createElement('strong');
title.textContent =
'同じ屋台に同名の商品がすでに登録されています。';
warning.appendChild(title);
const text =
document.createElement('div');
text.textContent =
'重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
warning.appendChild(text);
const list =
document.createElement('ul');
rows.forEach(
function (row) {
const item =
document.createElement('li');
const link =
document.createElement('a');
link.href =
mw.util.getUrl(
row.page_name
);
link.textContent =
(
row.menu_name ||
'商品'
) +
'(商品ID: ' +
row.menu_item_id +
')';
link.target = '_blank';
item.appendChild(link);
list.appendChild(item);
}
);
warning.appendChild(list);
warning.hidden = false;
}
function checkDuplicate() {
const menuName =
nameInput.value.trim();
if (
!stallSelect.value ||
!menuName
) {
clearWarning();
return;
}
const currentRequest =
++requestSerial;
resolveStallId().then(
function (stallId) {
if (
currentRequest !==
requestSerial
) {
return null;
}
if (!stallId) {
clearWarning();
return null;
}
return cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'name=menu_name,' +
'_pageName=page_name',
'stall_id=' +
stallId +
' AND name=' +
cargoQuote(
menuName
),
20
);
}
).then(
function (rows) {
if (
rows === null ||
rows === undefined
) {
return;
}
if (
currentRequest !==
requestSerial
) {
return;
}
/*
* 編集画面では
* 自分自身を重複候補から除外。
*/
const currentPage =
normalizePageName(
mw.config.get(
'wgPageName'
)
);
const duplicates =
rows.filter(
function (row) {
return (
normalizePageName(
row.page_name
) !==
currentPage
);
}
);
if (
duplicates.length === 0
) {
clearWarning();
return;
}
showWarning(
duplicates
);
}
).catch(
function (error) {
console.error(
'商品重複確認に失敗しました。',
error
);
clearWarning();
}
);
}
function scheduleCheck() {
window.clearTimeout(timer);
timer =
window.setTimeout(
checkDuplicate,
300
);
}
stallSelect.addEventListener(
'change',
scheduleCheck
);
nameInput.addEventListener(
'input',
scheduleCheck
);
nameInput.addEventListener(
'change',
scheduleCheck
);
/*
* 編集画面で既存値が入っている場合にも確認。
*/
scheduleCheck();
console.log(
'商品重複警告を初期化しました。'
);
}
if (
document.readyState ===
'loading'
) {
document.addEventListener(
'DOMContentLoaded',
setupStallMenuItemDuplicateWarning
);
} else {
setupStallMenuItemDuplicateWarning();
}
mw.hook(
'wikipage.content'
).add(
setupStallMenuItemDuplicateWarning
);
});
/* =========================================
* Venue:緯度・経度バリデーション
* ========================================= */
$(function () {
const latitudeInput = document.querySelector(
'input[name="Venue[latitude]"]'
);
const longitudeInput = document.querySelector(
'input[name="Venue[longitude]"]'
);
function setupVenueCoordinateValidation(
input,
label,
min,
max
) {
if (!input) {
return;
}
input.inputMode = 'decimal';
const validateCoordinate = function () {
const value = input.value.trim();
input.setCustomValidity('');
/*
* Venueでは緯度・経度自体は任意。
* ただし片方だけの入力は禁止する。
*/
if (value === '') {
const otherInput =
input === latitudeInput
? longitudeInput
: latitudeInput;
if (
otherInput &&
otherInput.value.trim() !== ''
) {
input.setCustomValidity(
'緯度と経度は両方入力するか、両方空欄にしてください。'
);
}
return;
}
/*
* 数値形式チェック
*/
if (!/^-?\d+(\.\d+)?$/.test(value)) {
input.setCustomValidity(
label + 'は数値で入力してください。'
);
return;
}
/*
* 日本付近の範囲チェック
*/
const number = Number(value);
if (number < min || number > max) {
input.setCustomValidity(
label +
'は' +
min +
'〜' +
max +
'の範囲で入力してください。'
);
}
};
input.addEventListener(
'input',
validateCoordinate
);
input.addEventListener(
'change',
validateCoordinate
);
input.addEventListener(
'invalid',
validateCoordinate
);
validateCoordinate();
return validateCoordinate;
}
const validateVenueLatitude =
setupVenueCoordinateValidation(
latitudeInput,
'緯度',
20,
46
);
const validateVenueLongitude =
setupVenueCoordinateValidation(
longitudeInput,
'経度',
122,
154
);
/*
* 一方の座標を変更した場合、
* 反対側のペア整合性も再検証する。
*/
if (
latitudeInput &&
validateVenueLongitude
) {
latitudeInput.addEventListener(
'input',
validateVenueLongitude
);
latitudeInput.addEventListener(
'change',
validateVenueLongitude
);
}
if (
longitudeInput &&
validateVenueLatitude
) {
longitudeInput.addEventListener(
'input',
validateVenueLatitude
);
longitudeInput.addEventListener(
'change',
validateVenueLatitude
);
}
});
/* =========================================
* Venue:地図ピン → 緯度・経度
* ========================================= */
$(function () {
const latInput = document.querySelector(
'input[name="Venue[latitude]"]'
);
const lonInput = document.querySelector(
'input[name="Venue[longitude]"]'
);
if (!latInput || !lonInput) {
return;
}
mw.loader.using('ext.pageforms.leaflet').then(function () {
const venueMarkerImagePath =
mw.config.get('wgExtensionAssetsPath') +
'/PageForms/libs/foreign/leaflet/images/';
const venueMarkerIcon = L.icon({
iconUrl:
venueMarkerImagePath +
'marker-icon.png',
iconRetinaUrl:
venueMarkerImagePath +
'marker-icon-2x.png',
shadowUrl:
venueMarkerImagePath +
'marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
if (
document.getElementById(
'matsuri-venue-location-map'
)
) {
return;
}
const mapDiv = document.createElement('div');
mapDiv.id = 'matsuri-venue-location-map';
mapDiv.style.height = '400px';
mapDiv.style.width = '100%';
mapDiv.style.marginBottom = '8px';
const status = document.createElement('div');
status.id = 'matsuri-venue-location-status';
status.setAttribute(
'aria-live',
'polite'
);
status.style.marginBottom = '8px';
status.style.fontWeight = '600';
const help = document.createElement('div');
help.textContent =
'地図をクリックして会場位置を指定してください。ピンはドラッグして微調整できます。';
help.style.marginBottom = '8px';
const controls = document.createElement('div');
controls.style.marginBottom = '8px';
const clearButton =
document.createElement('button');
clearButton.type = 'button';
clearButton.id =
'matsuri-venue-location-clear';
clearButton.textContent =
'位置情報をクリア';
controls.appendChild(
clearButton
);
const wrapper = document.createElement('div');
wrapper.appendChild(status);
wrapper.appendChild(help);
wrapper.appendChild(controls);
wrapper.appendChild(mapDiv);
const latRow = latInput.closest('tr');
if (!latRow || !latRow.parentNode) {
return;
}
const mapRow = document.createElement('tr');
const th = document.createElement('th');
th.textContent = '会場位置を地図から選択';
const td = document.createElement('td');
td.appendChild(wrapper);
mapRow.appendChild(th);
mapRow.appendChild(td);
latRow.parentNode.insertBefore(
mapRow,
latRow
);
function updateLocationUi() {
const latText =
latInput.value.trim();
const lonText =
lonInput.value.trim();
if (
latText === '' &&
lonText === ''
) {
status.textContent =
'位置情報:未登録';
clearButton.disabled = true;
return;
}
clearButton.disabled = false;
if (
latText !== '' &&
lonText !== ''
) {
status.textContent =
'位置情報:座標あり';
return;
}
status.textContent =
'位置情報:入力不完全';
}
clearButton.addEventListener(
'click',
function () {
if (
latInput.value.trim() === '' &&
lonInput.value.trim() === ''
) {
updateLocationUi();
return;
}
if (
!window.confirm(
'緯度・経度をクリアします。よろしいですか?'
)
) {
return;
}
latInput.value = '';
lonInput.value = '';
latInput.dispatchEvent(
new Event(
'input',
{ bubbles: true }
)
);
lonInput.dispatchEvent(
new Event(
'input',
{ bubbles: true }
)
);
latInput.dispatchEvent(
new Event(
'change',
{ bubbles: true }
)
);
lonInput.dispatchEvent(
new Event(
'change',
{ bubbles: true }
)
);
updateLocationUi();
}
);
latInput.addEventListener(
'input',
updateLocationUi
);
lonInput.addEventListener(
'input',
updateLocationUi
);
latInput.addEventListener(
'change',
updateLocationUi
);
lonInput.addEventListener(
'change',
updateLocationUi
);
updateLocationUi();
const hasCoordinates =
latInput.value.trim() !== '' &&
lonInput.value.trim() !== '' &&
!Number.isNaN(Number(latInput.value)) &&
!Number.isNaN(Number(lonInput.value));
/*
* 既存座標があればそこを表示。
* 新規・座標未登録なら日本全体を表示。
*/
const initialLat = hasCoordinates
? Number(latInput.value)
: 36.2048;
const initialLon = hasCoordinates
? Number(lonInput.value)
: 138.2529;
const map = L.map(mapDiv).setView(
[initialLat, initialLon],
hasCoordinates ? 17 : 5
);
L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{
maxZoom: 19,
attribution:
'© 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:
'© 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:
'© 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 === */