注意: 保存後、変更を確認するにはブラウザーのキャッシュを消去する必要がある場合があります。
- Firefox / Safari: Shift を押しながら 再読み込み をクリックするか、Ctrl-F5 または Ctrl-R を押してください (Mac では ⌘-R)
- Google Chrome: Ctrl-Shift-R を押してください (Mac では ⌘-Shift-R)
- Microsoft Edge: Ctrl を押しながら 最新の情報に更新 をクリックするか、Ctrl-F5 を押してください。
/* ========================================
* 屋台比較
* placement_id 正式版
*
* 最大4出店
*
* localStorage:
* matsuriWikiComparePlacements
*
* 1 placement =
* 1 festival + 1 year + 1 venue + 1 stall
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MAX_COMPARE = 4;
const api =
new mw.Api();
/*
* ページ表示中だけ使うキャッシュ
*/
const placementInfoCache = {};
/* =====================================
* localStorage
* ===================================== */
function getComparePlacements() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse( raw );
if ( !Array.isArray( ids ) ) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
function saveComparePlacements( ids ) {
mw.storage.set(
STORAGE_KEY,
JSON.stringify( ids )
);
}
/* =====================================
* Cargo共通処理
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return item.title || item;
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
)
];
}
function mapBy( rows, key ) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] === undefined ||
row[ key ] === null
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* Placement情報取得
* ===================================== */
function fetchPlacementInfo( ids ) {
if ( ids.length === 0 ) {
return Promise.resolve( {} );
}
const result = {};
const missingIds = [];
ids.forEach(
function ( id ) {
if (
placementInfoCache[ id ]
) {
result[ id ] =
placementInfoCache[ id ];
} else {
missingIds.push( id );
}
}
);
if ( missingIds.length === 0 ) {
return Promise.resolve(
result
);
}
return cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'stall_id=stall_id,' +
'festival_id=festival_id,' +
'venue_id=venue_id,' +
'year=year,' +
'location_note=location_note',
'placement_id IN (' +
makeInClause(
missingIds
) +
')',
100
).then(
function ( placements ) {
const stallIds =
uniqueIds(
placements.map(
function ( row ) {
return row.stall_id;
}
)
);
const festivalIds =
uniqueIds(
placements.map(
function ( row ) {
return row.festival_id;
}
)
);
const venueIds =
uniqueIds(
placements.map(
function ( row ) {
return row.venue_id;
}
)
);
return Promise.all( [
stallIds.length
? cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name,' +
'_pageName=page_name',
'stall_id IN (' +
makeInClause(
stallIds
) +
')',
100
)
: Promise.resolve( [] ),
festivalIds.length
? cargoQuery(
'Festivals',
'festival_id=festival_id,' +
'name=festival_name,' +
'_pageName=page_name',
'festival_id IN (' +
makeInClause(
festivalIds
) +
')',
100
)
: Promise.resolve( [] ),
venueIds.length
? cargoQuery(
'Venues',
'venue_id=venue_id,' +
'name=venue_name,' +
'_pageName=page_name',
'venue_id IN (' +
makeInClause(
venueIds
) +
')',
100
)
: Promise.resolve( [] )
] ).then(
function ( related ) {
return {
placements:
placements,
stalls:
related[ 0 ],
festivals:
related[ 1 ],
venues:
related[ 2 ]
};
}
);
}
).then(
function ( data ) {
const stallMap =
mapBy(
data.stalls,
'stall_id'
);
const festivalMap =
mapBy(
data.festivals,
'festival_id'
);
const venueMap =
mapBy(
data.venues,
'venue_id'
);
data.placements.forEach(
function ( placement ) {
const placementId =
String(
placement.placement_id
);
const info = {
placement:
placement,
stall:
stallMap[
String(
placement.stall_id
)
] || null,
festival:
festivalMap[
String(
placement.festival_id
)
] || null,
venue:
venueMap[
String(
placement.venue_id
)
] || null
};
placementInfoCache[
placementId
] = info;
result[
placementId
] = info;
}
);
return result;
}
);
}
/* =====================================
* メッセージ
* ===================================== */
function showMessage(
control,
message,
isError
) {
const element =
control.querySelector(
'.stall-compare-message'
);
if ( !element ) {
return;
}
element.textContent =
message;
element.classList.toggle(
'stall-compare-message-error',
Boolean( isError )
);
}
/* =====================================
* 比較ボタン生成
*
* 通常ページ
* stall-compare-placeholder-1
*
* 地図popup
* stall-map-compare-placeholder-1
*
* の両方に対応
* ===================================== */
function createCompareButtons() {
document
.querySelectorAll(
'.stall-compare-placeholder, ' +
'.stall-map-compare-placeholder'
)
.forEach(
function ( placeholder ) {
/*
* すでにbutton生成済み
*/
if (
placeholder.dataset.initialized ===
'1'
) {
return;
}
let placementId = '';
/*
* 通常ページ
*/
const normalPrefix =
'stall-compare-placeholder-';
/*
* 地図popup
*/
const mapPrefix =
'stall-map-compare-placeholder-';
if (
placeholder.id.startsWith(
normalPrefix
)
) {
placementId =
placeholder.id.substring(
normalPrefix.length
);
} else if (
placeholder.id.startsWith(
mapPrefix
)
) {
placementId =
placeholder.id.substring(
mapPrefix.length
);
} else {
return;
}
/*
* placement_idチェック
*/
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
/*
* 本物のbuttonを
* JavaScript側で作る
*/
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'stall-compare-button';
button.dataset.placementId =
placementId;
button.setAttribute(
'aria-pressed',
'false'
);
button.textContent =
'比較に追加';
placeholder.appendChild(
button
);
/*
* 二重生成防止
*/
placeholder.dataset.initialized =
'1';
}
);
}
/* =====================================
* ボタン状態更新
* ===================================== */
function updateCompareButtons() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'.stall-compare-button'
)
.forEach(
function ( button ) {
const placementId =
String(
button.dataset
.placementId || ''
);
const selected =
ids.includes(
placementId
);
if ( selected ) {
button.textContent =
'比較から外す';
button.classList.add(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'true'
);
} else {
button.textContent =
'比較に追加';
button.classList.remove(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 固定比較トレイ生成
* ===================================== */
function createCompareTray() {
if (
document.getElementById(
'stall-compare-tray'
)
) {
return;
}
const tray =
document.createElement(
'div'
);
tray.id =
'stall-compare-tray';
tray.className =
'stall-compare-tray';
tray.setAttribute(
'aria-live',
'polite'
);
const header =
document.createElement(
'div'
);
header.className =
'stall-compare-tray-header';
const title =
document.createElement(
'strong'
);
title.className =
'stall-compare-tray-title';
title.textContent =
'比較候補';
const count =
document.createElement(
'span'
);
count.className =
'stall-compare-tray-count';
header.appendChild(
title
);
header.appendChild(
count
);
const items =
document.createElement(
'div'
);
items.className =
'stall-compare-tray-items';
const actions =
document.createElement(
'div'
);
actions.className =
'stall-compare-tray-actions';
const clearButton =
document.createElement(
'button'
);
clearButton.type =
'button';
clearButton.className =
'stall-compare-clear';
clearButton.textContent =
'すべて外す';
const compareLink =
document.createElement(
'a'
);
compareLink.className =
'stall-compare-open';
compareLink.href =
mw.util.getUrl(
'屋台比較'
);
compareLink.textContent =
'比較する';
actions.appendChild(
clearButton
);
actions.appendChild(
compareLink
);
tray.appendChild(
header
);
tray.appendChild(
items
);
tray.appendChild(
actions
);
document.body.appendChild(
tray
);
}
/* =====================================
* 比較トレイ更新
* ===================================== */
function updateCompareTray() {
createCompareTray();
const tray =
document.getElementById(
'stall-compare-tray'
);
if ( !tray ) {
return;
}
const ids =
getComparePlacements();
const count =
tray.querySelector(
'.stall-compare-tray-count'
);
const items =
tray.querySelector(
'.stall-compare-tray-items'
);
const compareLink =
tray.querySelector(
'.stall-compare-open'
);
if ( count ) {
count.textContent =
ids.length +
' / ' +
MAX_COMPARE;
}
if ( ids.length === 0 ) {
tray.classList.remove(
'stall-compare-tray-visible'
);
if ( items ) {
items.innerHTML = '';
}
return;
}
tray.classList.add(
'stall-compare-tray-visible'
);
if ( compareLink ) {
if ( ids.length >= 2 ) {
compareLink.classList.remove(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'false'
);
} else {
compareLink.classList.add(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'true'
);
}
}
if ( !items ) {
return;
}
items.textContent =
'屋台情報を読み込み中…';
fetchPlacementInfo(
ids
).then(
function ( placementInfo ) {
const currentIds =
getComparePlacements();
items.innerHTML =
'';
currentIds.forEach(
function ( placementId ) {
const info =
placementInfo[
placementId
];
if ( !info ) {
return;
}
const item =
document.createElement(
'div'
);
item.className =
'stall-compare-tray-item';
const text =
document.createElement(
'div'
);
const name =
document.createElement(
info.stall &&
info.stall.page_name
? 'a'
: 'span'
);
if (
info.stall &&
info.stall.page_name
) {
name.href =
mw.util.getUrl(
info.stall.page_name
);
}
name.className =
'stall-compare-tray-item-name';
name.textContent =
info.stall
? info.stall.stall_name
: '屋台';
const context =
document.createElement(
'div'
);
context.className =
'stall-compare-tray-item-context';
const parts = [];
if (
info.placement &&
info.placement.year
) {
parts.push(
info.placement.year +
'年'
);
}
if (
info.festival &&
info.festival.festival_name
) {
parts.push(
info.festival
.festival_name
);
}
if (
info.venue &&
info.venue.venue_name
) {
parts.push(
info.venue
.venue_name
);
}
context.textContent =
parts.join(
' / '
);
text.appendChild(
name
);
text.appendChild(
context
);
const removeButton =
document.createElement(
'button'
);
removeButton.type =
'button';
removeButton.className =
'stall-compare-tray-remove';
removeButton.dataset.placementId =
placementId;
removeButton.setAttribute(
'aria-label',
(
info.stall
? info.stall.stall_name
: '屋台'
) +
'を比較候補から外す'
);
removeButton.textContent =
'×';
item.appendChild(
text
);
item.appendChild(
removeButton
);
items.appendChild(
item
);
}
);
}
).catch(
function ( error ) {
console.error(
'比較トレイ取得エラー:',
error
);
items.textContent =
'比較候補を読み込めませんでした。';
}
);
}
/* =====================================
* 詳細ページ
* 「比較に追加」
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-button'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
if (
!/^\d+$/.test(
placementId
)
) {
return;
}
const control =
button.closest(
'.stall-compare-control'
);
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
/*
* すでに選択中
*/
if ( index !== -1 ) {
ids.splice(
index,
1
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補から外しました。',
false
);
}
return;
}
/*
* 最大4件
*/
if (
ids.length >=
MAX_COMPARE
) {
if ( control ) {
showMessage(
control,
'比較できる出店は最大4件です。',
true
);
}
return;
}
ids.push(
placementId
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補に追加しました(' +
ids.length +
'/4)。',
false
);
}
}
);
/* =====================================
* トレイから1件削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-tray-remove'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
let ids =
getComparePlacements();
ids =
ids.filter(
function ( id ) {
return id !== placementId;
}
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 全削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-clear'
);
if ( !button ) {
return;
}
event.preventDefault();
saveComparePlacements(
[]
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 1件時の比較リンク無効
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'.stall-compare-open-disabled'
);
if ( !link ) {
return;
}
event.preventDefault();
}
);
/* =====================================
* 初期化
* ===================================== */
function initPlacementCompare() {
createCompareButtons();
createCompareTray();
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
initPlacementCompare();
mw.hook(
'wikipage.content'
).add(
function () {
initPlacementCompare();
}
);
/* =====================================
* 地図popup 比較リンク
* ===================================== */
function getMapComparePlacementId( link ) {
if ( !link ) {
return '';
}
const href =
link.getAttribute( 'href' ) || '';
const match =
href.match(
/#compare-placement-(\d+)$/
);
if ( !match ) {
return '';
}
return match[ 1 ];
}
function updateMapCompareLinks() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'a[href*="#compare-placement-"]'
)
.forEach(
function ( link ) {
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
const selected =
ids.includes(
placementId
);
link.classList.add(
'stall-map-compare-link'
);
link.dataset.placementId =
placementId;
if ( selected ) {
link.textContent =
'比較から外す';
link.classList.add(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'true'
);
} else {
link.textContent =
'比較に追加';
link.classList.remove(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 地図popupクリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'a[href*="#compare-placement-"]'
);
if ( !link ) {
return;
}
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
event.preventDefault();
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
if ( index !== -1 ) {
ids.splice(
index,
1
);
} else {
if (
ids.length >=
MAX_COMPARE
) {
link.textContent =
'最大4件までです';
return;
}
ids.push(
placementId
);
}
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* Leaflet popup生成監視
* ===================================== */
const mapCompareLinkObserver =
new MutationObserver(
function ( mutations ) {
let needsUpdate =
false;
mutations.forEach(
function ( mutation ) {
mutation.addedNodes.forEach(
function ( node ) {
if (
node.nodeType !== 1
) {
return;
}
if (
node.matches &&
node.matches(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
return;
}
if (
node.querySelector &&
node.querySelector(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
}
}
);
}
);
if ( needsUpdate ) {
updateMapCompareLinks();
}
}
);
mapCompareLinkObserver.observe(
document.body,
{
childList: true,
subtree: true
}
);
/*
* ★ この } ); が
* placement比較用mw.loaderの終了
*/
} );
/* ========================================
* 祭りページ
* 屋台名・商品名検索
* ======================================== */
mw.loader.using( [
'mediawiki.api'
] ).then( function () {
'use strict';
const cards =
Array.from(
document.querySelectorAll(
'.festival-stall-search-card'
)
);
/*
* 屋台カードがないページでは終了
*/
if (
cards.length === 0
) {
return;
}
const api =
new mw.Api();
/* =====================================
* 文字列正規化
* ===================================== */
function normalizeSearchText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
/*
* 全角・半角を可能な範囲で統一
*/
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
/*
* 大文字小文字を統一
*/
text =
text.toLowerCase();
/*
* 連続空白を統一
*/
text =
text.replace(
/\s+/g,
' '
);
return text;
}
/* =====================================
* Cargo Query
* ===================================== */
function cargoQuery(
table,
fields,
where
) {
const params = {
action:
'cargoquery',
tables:
table,
fields:
fields,
limit:
500,
format:
'json'
};
if (
where
) {
params.where =
where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return (
item.title ||
item
);
}
);
}
);
}
/* =====================================
* placement_id一覧
* ===================================== */
const placementIds =
cards
.map(
function ( card ) {
return String(
card.dataset
.placementId ||
''
);
}
)
.filter(
function ( id ) {
return (
/^\d+$/.test(
id
) &&
id !== '0'
);
}
);
if (
placementIds.length === 0
) {
return;
}
/* =====================================
* 屋台一覧 ↔ Leaflet地図連動
*
* placement_id を使って
* markerを表示・非表示
* ===================================== */
const festivalMapMarkerIndex =
{};
/*
* 地図markerの登録が完了したか
*/
let festivalMapMarkerIndexReady =
false;
/*
* 地図生成前に検索された場合に備えて
* 最新の表示対象を保持
*/
let pendingVisiblePlacementIds =
placementIds.slice();
/*
* 地図初期化待ち
*/
let mapIndexTimer =
null;
let mapIndexAttempts =
0;
const MAX_MAP_INDEX_ATTEMPTS =
40;
/* =====================================
* marker popupから
* placement_idを取得
*
* popup内には
* #compare-placement-3
* のようなリンクが存在する
* ===================================== */
function getPlacementIdFromMapMarker(
marker
) {
if (
!marker ||
typeof marker.getPopup !==
'function'
) {
return '';
}
const popup =
marker.getPopup();
if (
!popup ||
typeof popup.getContent !==
'function'
) {
return '';
}
const content =
popup.getContent();
let popupText =
'';
/*
* 通常はHTML文字列
*/
if (
typeof content ===
'string'
) {
popupText =
content;
} else if (
content &&
typeof content.outerHTML ===
'string'
) {
popupText =
content.outerHTML;
} else if (
content &&
typeof content.innerHTML ===
'string'
) {
popupText =
content.innerHTML;
}
const match =
popupText.match(
/compare-placement-(\d+)/
);
if (
!match
) {
return '';
}
return match[
1
];
}
/* =====================================
* Maps拡張のmarkerを
* placement_idごとに登録
* ===================================== */
function buildFestivalMapMarkerIndex() {
if (
festivalMapMarkerIndexReady
) {
return true;
}
if (
!Array.isArray(
window.mapsLeafletList
)
) {
return false;
}
const expectedIds =
new Set(
placementIds.map(
String
)
);
window.mapsLeafletList.forEach(
function ( jqueryMap ) {
if (
!jqueryMap ||
!jqueryMap.mapContent ||
!jqueryMap.mapContent
.markerLayer
) {
return;
}
const markerLayer =
jqueryMap
.mapContent
.markerLayer;
if (
typeof markerLayer
.getLayers !==
'function'
) {
return;
}
const markers =
markerLayer.getLayers();
markers.forEach(
function ( marker ) {
const placementId =
getPlacementIdFromMapMarker(
marker
);
/*
* この祭りページの
* placementだけ登録
*/
if (
!placementId ||
!expectedIds.has(
placementId
)
) {
return;
}
festivalMapMarkerIndex[
placementId
] = {
marker:
marker,
markerLayer:
markerLayer,
/*
* このmarkerが所属する地図DOM
*/
mapElement:
jqueryMap &&
jqueryMap[
0
]
? jqueryMap[
0
]
: null
};
}
);
}
);
/*
* 全placementのmarkerが
* 見つかった場合だけ連動開始
*
* 中途半端な状態では
* 地図を変更しない
*/
festivalMapMarkerIndexReady =
Array.from(
expectedIds
).every(
function (
placementId
) {
return Boolean(
festivalMapMarkerIndex[
placementId
]
);
}
);
return (
festivalMapMarkerIndexReady
);
}
/* =====================================
* marker表示状態を変更
* ===================================== */
function applyMapMarkerFilter(
visiblePlacementIds
) {
const visibleIds =
new Set(
visiblePlacementIds.map(
String
)
);
Object.keys(
festivalMapMarkerIndex
).forEach(
function ( placementId ) {
const item =
festivalMapMarkerIndex[
placementId
];
if (
!item ||
!item.marker ||
!item.markerLayer
) {
return;
}
const marker =
item.marker;
const markerLayer =
item.markerLayer;
/*
* markerが現在表示されているか
*/
const isShown =
typeof markerLayer
.hasLayer ===
'function'
? markerLayer.hasLayer(
marker
)
: true;
/*
* 表示対象
*/
if (
visibleIds.has(
placementId
)
) {
if (
!isShown &&
typeof markerLayer
.addLayer ===
'function'
) {
markerLayer.addLayer(
marker
);
}
/*
* 非表示対象
*/
} else {
if (
isShown &&
typeof markerLayer
.removeLayer ===
'function'
) {
markerLayer.removeLayer(
marker
);
}
}
}
);
}
/* =====================================
* 地図初期化待ち
* ===================================== */
function scheduleMapMarkerIndex() {
if (
festivalMapMarkerIndexReady
) {
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
/*
* 二重タイマー防止
*/
if (
mapIndexTimer !== null
) {
return;
}
function tryIndex() {
mapIndexTimer =
null;
if (
buildFestivalMapMarkerIndex()
) {
/*
* 地図準備完了後、
* 最新の絞り込み状態を反映
*/
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
mapIndexAttempts +=
1;
if (
mapIndexAttempts >=
MAX_MAP_INDEX_ATTEMPTS
) {
console.warn(
'祭り屋台地図:placement_idとmarkerを対応付けできませんでした。'
);
return;
}
mapIndexTimer =
window.setTimeout(
tryIndex,
100
);
}
tryIndex();
}
/* =====================================
* 一覧の検索結果を
* 地図へ反映
* ===================================== */
function syncMapMarkers(
visiblePlacementIds
) {
pendingVisiblePlacementIds =
visiblePlacementIds
.map(
String
);
if (
buildFestivalMapMarkerIndex()
) {
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
/*
* Maps側がまだ初期化されていれば待つ
*/
scheduleMapMarkerIndex();
}
/* =====================================
* placement_idのmarkerを開く
* ===================================== */
function openPlacementOnMap(
placementId
) {
const id =
String(
placementId ||
''
);
if (
!/^\d+$/.test(
id
) ||
id === '0'
) {
return false;
}
/*
* marker index未完成なら
* 一度構築を試す
*/
if (
!festivalMapMarkerIndex[
id
]
) {
buildFestivalMapMarkerIndex();
}
const item =
festivalMapMarkerIndex[
id
];
if (
!item ||
!item.marker
) {
console.warn(
'地図markerが見つかりません:',
id
);
return false;
}
const marker =
item.marker;
/*
* 万一markerが非表示なら
* 地図へ戻す
*/
if (
item.markerLayer &&
typeof item.markerLayer
.hasLayer ===
'function' &&
!item.markerLayer.hasLayer(
marker
) &&
typeof item.markerLayer
.addLayer ===
'function'
) {
item.markerLayer.addLayer(
marker
);
}
/*
* 地図までスクロール
*/
if (
item.mapElement &&
typeof item.mapElement
.scrollIntoView ===
'function'
) {
item.mapElement.scrollIntoView(
{
behavior:
'smooth',
block:
'center'
}
);
}
/*
* 少し待ってpopupを開く
*/
window.setTimeout(
function () {
if (
typeof marker.openPopup ===
'function'
) {
marker.openPopup();
}
},
300
);
return true;
}
/* =====================================
* 各屋台カード
* 「地図で見る」ボタン生成
* ===================================== */
function createMapViewButtons() {
cards.forEach(
function ( card ) {
/*
* 二重生成防止
*/
if (
card.querySelector(
'.festival-stall-map-view'
)
) {
return;
}
const placementId =
String(
card.dataset
.placementId ||
''
);
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
const wrapper =
document.createElement(
'div'
);
wrapper.className =
'festival-stall-map-view';
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'festival-stall-map-view-button';
button.dataset.placementId =
placementId;
button.textContent =
'地図で見る';
button.setAttribute(
'aria-label',
'この屋台を地図で見る'
);
wrapper.appendChild(
button
);
/*
* 比較ボタンの近くへ配置
*/
const compareControl =
card.querySelector(
'.stall-compare-control'
);
if (
compareControl &&
compareControl.parentNode
) {
compareControl.parentNode
.insertBefore(
wrapper,
compareControl
.nextSibling
);
} else {
/*
* 比較ボタンが見つからない場合は
* カード末尾
*/
card.appendChild(
wrapper
);
}
}
);
}
/* =====================================
* 「地図で見る」クリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.festival-stall-map-view-button'
);
if (
!button
) {
return;
}
const placementId =
String(
button.dataset
.placementId ||
''
);
const opened =
openPlacementOnMap(
placementId
);
/*
* Maps初期化前だった場合
*/
if (
!opened
) {
scheduleMapMarkerIndex();
button.disabled =
true;
button.textContent =
'地図を準備中…';
window.setTimeout(
function () {
button.disabled =
false;
button.textContent =
'地図で見る';
openPlacementOnMap(
placementId
);
},
500
);
}
}
);
/* =====================================
* 検索UI
* ===================================== */
const searchBox =
document.createElement(
'div'
);
searchBox.className =
'festival-stall-search';
const label =
document.createElement(
'label'
);
label.className =
'festival-stall-search-label';
label.textContent =
'屋台を検索';
const input =
document.createElement(
'input'
);
input.type =
'search';
input.className =
'festival-stall-search-input';
input.placeholder =
'屋台名・商品名を入力';
input.setAttribute(
'autocomplete',
'off'
);
input.setAttribute(
'aria-label',
'屋台名または商品名で検索'
);
/* =====================================
* フィルターselect
* ===================================== */
function createFilterSelect(
labelText,
className,
allText
) {
const wrapper =
document.createElement(
'label'
);
wrapper.className =
'festival-stall-filter';
const title =
document.createElement(
'span'
);
title.className =
'festival-stall-filter-label';
title.textContent =
labelText;
const select =
document.createElement(
'select'
);
select.className =
'festival-stall-filter-select ' +
className;
const allOption =
document.createElement(
'option'
);
allOption.value =
'';
allOption.textContent =
allText;
select.appendChild(
allOption
);
wrapper.appendChild(
title
);
wrapper.appendChild(
select
);
return {
wrapper:
wrapper,
select:
select
};
}
/*
* カテゴリ
*/
const categoryFilter =
createFilterSelect(
'カテゴリ',
'festival-stall-category-filter',
'すべて'
);
/*
* 会場
*/
const venueFilter =
createFilterSelect(
'会場',
'festival-stall-venue-filter',
'すべて'
);
const categorySelect =
categoryFilter.select;
const venueSelect =
venueFilter.select;
/*
* フィルター行
*/
const filterRow =
document.createElement(
'div'
);
filterRow.className =
'festival-stall-search-filters';
filterRow.appendChild(
categoryFilter.wrapper
);
filterRow.appendChild(
venueFilter.wrapper
);
/*
* 絞り込みリセット
* ===================================== */
const resetButton =
document.createElement(
'button'
);
resetButton.type =
'button';
resetButton.className =
'festival-stall-search-reset';
resetButton.textContent =
'絞り込みをリセット';
resetButton.setAttribute(
'aria-label',
'屋台の検索条件をすべてリセット'
);
resetButton.disabled =
true;
const count =
document.createElement(
'div'
);
count.className =
'festival-stall-search-count';
/* =====================================
* 検索結果0件メッセージ
* ===================================== */
const noResults =
document.createElement(
'div'
);
noResults.className =
'festival-stall-search-empty';
noResults.textContent =
'条件に一致する屋台はありません。検索条件を変更してください。';
noResults.hidden =
true;
noResults.setAttribute(
'role',
'status'
);
label.appendChild(
input
);
searchBox.appendChild(
label
);
searchBox.appendChild(
filterRow
);
/*
* リセット
*/
searchBox.appendChild(
resetButton
);
searchBox.appendChild(
count
);
searchBox.appendChild(
noResults
);
/*
* 最初の屋台カードの直前に表示
*/
/*
* 検索UIの表示位置
*/
const searchAnchor =
document.getElementById(
'festival-stall-search-anchor'
);
if (
searchAnchor
) {
searchAnchor.appendChild(
searchBox
);
} else {
/*
* 古いテンプレート等への
* フォールバック
*/
cards[
0
].parentNode.insertBefore(
searchBox,
cards[
0
]
);
}
/* =====================================
* カードごとの検索文字列
*
* 最初はカード本文だけ
* ===================================== */
const searchIndex = {};
/*
* select候補
*/
const categoryOptions =
new Map();
const venueOptions =
new Map();
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const category =
String(
card.dataset
.category ||
''
).trim();
const venueName =
String(
card.dataset
.venueName ||
''
).trim();
const normalizedCategory =
normalizeSearchText(
category
);
const normalizedVenue =
normalizeSearchText(
venueName
);
/*
* placementごとの検索情報
*/
searchIndex[
placementId
] = {
text:
normalizeSearchText(
card.textContent
),
category:
normalizedCategory,
venueName:
normalizedVenue
};
/*
* カテゴリselect候補
*/
if (
normalizedCategory &&
!categoryOptions.has(
normalizedCategory
)
) {
categoryOptions.set(
normalizedCategory,
category
);
}
/*
* 会場select候補
*/
if (
normalizedVenue &&
!venueOptions.has(
normalizedVenue
)
) {
venueOptions.set(
normalizedVenue,
venueName
);
}
}
);
/* =====================================
* select option生成
* ===================================== */
function fillFilterOptions(
select,
optionMap
) {
const options =
Array.from(
optionMap.entries()
);
/*
* 表示名で並び替え
*/
options.sort(
function ( a, b ) {
return a[
1
].localeCompare(
b[
1
],
'ja'
);
}
);
options.forEach(
function ( optionData ) {
const value =
optionData[
0
];
const label =
optionData[
1
];
const option =
document.createElement(
'option'
);
option.value =
value;
option.textContent =
label;
select.appendChild(
option
);
}
);
}
fillFilterOptions(
categorySelect,
categoryOptions
);
fillFilterOptions(
venueSelect,
venueOptions
);
/* =====================================
* 件数表示
* ===================================== */
function updateCount(
visible
) {
count.textContent =
'表示:' +
visible +
' / ' +
cards.length +
'件';
}
updateCount(
cards.length
);
/* =====================================
* 検索実行
* ===================================== */
function applySearch() {
/*
* フリーワード
*/
const keyword =
normalizeSearchText(
input.value
);
/*
* カテゴリ
*/
const selectedCategory =
categorySelect.value;
/*
* 会場
*/
const selectedVenue =
venueSelect.value;
let visible =
0;
/*
* 地図に残すplacement_id
*/
const visiblePlacementIds =
[];
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const index =
searchIndex[
placementId
] || {
text:
'',
category:
'',
venueName:
''
};
/* =============================
* フリーワード
* ============================= */
const keywordMatched =
!keyword ||
index.text.includes(
keyword
);
/* =============================
* カテゴリ
* ============================= */
const categoryMatched =
!selectedCategory ||
index.category ===
selectedCategory;
/* =============================
* 会場
* ============================= */
const venueMatched =
!selectedVenue ||
index.venueName ===
selectedVenue;
/* =============================
* AND条件
* ============================= */
const matched =
keywordMatched &&
categoryMatched &&
venueMatched;
if (
matched
) {
card.style.display =
'';
visible +=
1;
/*
* 地図にも残す
*/
visiblePlacementIds.push(
placementId
);
} else {
card.style.display =
'none';
}
}
);
updateCount(
visible
);
/*
* 0件メッセージ
*/
noResults.hidden =
visible !== 0;
/*
* 検索条件が1つでもあれば
* リセットボタンを有効化
*/
resetButton.disabled =
(
normalizeSearchText(
input.value
) === '' &&
categorySelect.value === '' &&
venueSelect.value === ''
);
/*
* 地図を一覧と同期
*/
syncMapMarkers(
visiblePlacementIds
);
}
/*
* 各カードへ
* 地図で見るボタン
*/
createMapViewButtons();
/*
* 初期状態
*
* 最初は全placementを表示
*/
syncMapMarkers(
placementIds
);
input.addEventListener(
'input',
applySearch
);
categorySelect.addEventListener(
'change',
applySearch
);
venueSelect.addEventListener(
'change',
applySearch
);
/* =====================================
* 絞り込みをすべてリセット
* ===================================== */
resetButton.addEventListener(
'click',
function () {
/*
* フリーワード
*/
input.value =
'';
/*
* カテゴリ
*/
categorySelect.value =
'';
/*
* 会場
*/
venueSelect.value =
'';
/*
* 一覧・件数・0件表示・
* 地図markerをすべて再計算
*/
applySearch();
/*
* 続けて検索しやすくする
*/
input.focus();
}
);
/* =====================================
* Placement → Offering取得
* ===================================== */
cargoQuery(
'FestivalStallMenuOfferings',
'placement_id=placement_id,' +
'menu_item_id=menu_item_id',
'placement_id IN (' +
placementIds.join(
','
) +
')'
).then(
function ( offerings ) {
const menuItemIds =
[
...new Set(
offerings
.map(
function (
offering
) {
return String(
offering
.menu_item_id ||
''
);
}
)
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
)
];
/*
* メニューが1件も無い
*/
if (
menuItemIds.length === 0
) {
return {
offerings:
offerings,
menus:
[]
};
}
/* =================================
* MenuItem名取得
* ================================= */
return cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'name=menu_name',
'menu_item_id IN (' +
menuItemIds.join(
','
) +
')'
).then(
function ( menus ) {
return {
offerings:
offerings,
menus:
menus
};
}
);
}
).then(
function ( data ) {
if (
!data
) {
return;
}
/* =================================
* menu_item_id → 商品名
* ================================= */
const menuNameMap =
{};
data.menus.forEach(
function ( menu ) {
menuNameMap[
String(
menu.menu_item_id
)
] =
menu.menu_name ||
'';
}
);
/* =================================
* placement_id → 商品名[]
* ================================= */
const placementMenus =
{};
data.offerings.forEach(
function ( offering ) {
const placementId =
String(
offering
.placement_id ||
''
);
const menuItemId =
String(
offering
.menu_item_id ||
''
);
const menuName =
menuNameMap[
menuItemId
] || '';
if (
!menuName
) {
return;
}
if (
!placementMenus[
placementId
]
) {
placementMenus[
placementId
] = [];
}
placementMenus[
placementId
].push(
menuName
);
}
);
/* =================================
* 商品名を検索インデックスへ追加
* ================================= */
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const menuNames =
placementMenus[
placementId
] || [];
if (
searchIndex[
placementId
]
) {
searchIndex[
placementId
].text =
normalizeSearchText(
(
searchIndex[
placementId
].text ||
''
) +
' ' +
menuNames.join(
' '
)
);
}
}
);
/*
* 商品データ取得後、
* 入力済み検索を再判定
*/
applySearch();
}
).catch(
function ( error ) {
/*
* 商品データ取得に失敗しても
* 屋台名検索は使えるようにする
*/
console.error(
'屋台商品検索データ取得エラー:',
error
);
}
);
} );
/* ========================================
* 屋台比較ページ
* placement_id 正式版
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const compareRoot =
document.getElementById(
'stall-compare-page'
);
/*
* 屋台比較ページ以外では終了
*/
if ( !compareRoot ) {
return;
}
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MIN_COMPARE = 2;
const MAX_COMPARE = 4;
const api =
new mw.Api();
/* =====================================
* localStorage
* ===================================== */
function getPlacementIds() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse(
raw
);
if (
!Array.isArray(
ids
)
) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
/* =====================================
* Cargo
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return (
item.title ||
item
);
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return (
id &&
/^\d+$/.test(
id
)
);
}
)
)
];
}
function mapBy(
rows,
key
) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] ===
undefined
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* 表示ヘルパー
* ===================================== */
function textOrDash(
value
) {
if (
value === undefined ||
value === null ||
value === ''
) {
return '―';
}
return String(
value
);
}
function cleanNumber(
value
) {
if (
value === undefined ||
value === null ||
String( value ).trim() === ''
) {
return '';
}
const number =
Number(
value
);
if (
!Number.isFinite(
number
)
) {
return '';
}
if (
Number.isInteger(
number
)
) {
return String(
number
);
}
return String(
Math.round(
number * 100
) / 100
);
}
/* =====================================
* 比較計算用数値
* ===================================== */
function toFiniteNumber(
value
) {
if (
value === undefined ||
value === null ||
String( value ).trim() === ''
) {
return null;
}
const number =
Number(
value
);
if (
!Number.isFinite(
number
)
) {
return null;
}
return number;
}
function formatHours(
placement
) {
if ( !placement ) {
return '―';
}
const open =
placement.opening_time || '';
const close =
placement.closing_time || '';
if (
open &&
close
) {
return (
open +
'~' +
close
);
}
if ( open ) {
return (
open +
'~'
);
}
if ( close ) {
return (
'~' +
close
);
}
if (
placement.hours_note
) {
return placement.hours_note;
}
return '未確認';
}
function formatPositionStatus(
status
) {
switch ( status ) {
case 'exact':
return '正確な位置';
case 'approximate':
return 'おおよその位置';
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;
}
/*
* 数値形式チェック
*/
if (!/^-?\d+(\.\d+)?$/.test(value)) {
input.setCustomValidity(
label + 'は数値で入力してください。'
);
return;
}
/*
* 範囲チェック
*/
const number = Number(value);
if (number < min || number > max) {
input.setCustomValidity(
label +
'は' +
min +
'〜' +
max +
'の範囲で入力してください。'
);
}
};
input.addEventListener(
'input',
validateCoordinate
);
input.addEventListener(
'change',
validateCoordinate
);
input.addEventListener(
'invalid',
validateCoordinate
);
validateCoordinate();
/*
* position_status変更時に
* 再チェックできるよう関数を返す。
*/
return validateCoordinate;
}
const validateLatitude =
setupCoordinateValidation(
latitudeInput,
'緯度',
-90,
90
);
const validateLongitude =
setupCoordinateValidation(
longitudeInput,
'経度',
-180,
180
);
/*
* 位置情報の状態を変更した場合、
* 緯度・経度を再検証する。
*/
if (positionSelect) {
positionSelect.addEventListener(
'change',
function () {
if (validateLatitude) {
validateLatitude();
}
if (validateLongitude) {
validateLongitude();
}
}
);
}
const sourceUrlInput = document.querySelector(
'input[name="FestivalStallPlacement[source_url]"]'
);
if (sourceUrlInput) {
sourceUrlInput.inputMode = 'url';
const validateSourceUrl = function () {
const value = sourceUrlInput.value.trim();
sourceUrlInput.setCustomValidity('');
if (value === '') {
return;
}
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
sourceUrlInput.setCustomValidity(
'情報元URLは http:// または https:// で始まるURLを入力してください。'
);
}
} catch (e) {
sourceUrlInput.setCustomValidity(
'情報元URLを正しいURL形式で入力してください。'
);
}
};
sourceUrlInput.addEventListener('input', validateSourceUrl);
sourceUrlInput.addEventListener('change', validateSourceUrl);
sourceUrlInput.addEventListener('invalid', validateSourceUrl);
validateSourceUrl();
}
const sortOrderInput = document.querySelector(
'input[name="FestivalStallPlacement[sort_order]"]'
);
if (sortOrderInput) {
sortOrderInput.inputMode = 'numeric';
const validateSortOrder = function () {
const value = sortOrderInput.value.trim();
sortOrderInput.setCustomValidity('');
if (value !== '' && !/^\d+$/.test(value)) {
sortOrderInput.setCustomValidity(
'表示順は0以上の整数で入力してください(例:1)'
);
}
};
sortOrderInput.addEventListener('input', validateSortOrder);
sortOrderInput.addEventListener('change', validateSortOrder);
sortOrderInput.addEventListener('invalid', validateSortOrder);
validateSortOrder();
}
});
/**
* FestivalStallPlacement - 最終確認日の未来日チェック
*/
(function () {
'use strict';
function setupLastConfirmedValidation() {
const dateInputs = document.querySelectorAll(
'input[name="FestivalStallPlacement[last_confirmed]"]'
);
dateInputs.forEach(function (dateInput) {
if (dateInput.dataset.lastConfirmedValidation === '1') {
return;
}
dateInput.dataset.lastConfirmedValidation = '1';
function getVisibleInput() {
const widget = dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
return widget.querySelector('input[type="text"]');
}
function getErrorElement() {
const widget = dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
let error = widget.parentNode.querySelector(
'.stall-last-confirmed-error'
);
if (!error) {
error = document.createElement('div');
error.className = 'stall-last-confirmed-error';
error.setAttribute('role', 'alert');
error.hidden = true;
widget.insertAdjacentElement('afterend', error);
}
return error;
}
function showError() {
const visibleInput = getVisibleInput();
const error = getErrorElement();
if (!visibleInput || !error) {
return;
}
let message;
if (dateInput.validity.rangeOverflow) {
const maxDate = dateInput.max.replace(/-/g, '/');
message =
'未来の日付は入力できません。' +
maxDate +
'以前の日付を入力してください。';
} else {
message =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}
error.textContent = message;
error.hidden = false;
visibleInput.setAttribute('aria-invalid', 'true');
}
function clearError() {
const visibleInput = getVisibleInput();
const error = getErrorElement();
if (error) {
error.hidden = true;
error.textContent = '';
}
if (visibleInput) {
visibleInput.removeAttribute('aria-invalid');
}
}
/*
* 非表示の date input に対する
* ブラウザ標準エラー表示を止める。
*/
dateInput.addEventListener('invalid', function (event) {
event.preventDefault();
showError();
const visibleInput = getVisibleInput();
if (visibleInput) {
window.setTimeout(function () {
visibleInput.focus();
}, 0);
}
});
/*
* ユーザーが日付を修正したら
* 有効になった時点でエラーを消す。
*/
const visibleInput = getVisibleInput();
if (visibleInput) {
['input', 'change'].forEach(function (eventName) {
visibleInput.addEventListener(eventName, function () {
window.setTimeout(function () {
if (dateInput.validity.valid) {
clearError();
} else if (dateInput.validity.rangeOverflow) {
showError();
}
}, 0);
});
});
}
});
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupLastConfirmedValidation
);
} else {
setupLastConfirmedValidation();
}
mw.hook('wikipage.content').add(function () {
setupLastConfirmedValidation();
});
})();
/**
* FestivalStallPlacement
* Cargo既存レコード候補警告 V2
*
* 同じ festival + year + venue + stall があれば
* 警告と既存ページへのリンクを表示する。
* 保存自体は禁止しない。
*/
mw.loader.using([
'mediawiki.api',
'mediawiki.util'
]).then(function () {
'use strict';
const api = new mw.Api();
function setupDuplicateWarning() {
const form = document.getElementById('pfForm');
if (!form) {
return;
}
if (form.dataset.duplicateWarningV2 === '1') {
return;
}
const table = form.querySelector('.formtable');
if (!table) {
return;
}
form.dataset.duplicateWarningV2 = '1';
const warning = document.createElement('div');
warning.className = 'stall-duplicate-warning';
warning.setAttribute('role', 'status');
warning.hidden = true;
/*
* 表の中ではなく、表の直前に置く。
* 警告表示でフォームの列幅を崩さない。
*/
table.insertAdjacentElement('beforebegin', warning);
let timer = null;
let requestId = 0;
function escapeCargo(value) {
return String(value).replace(/'/g, "''");
}
function getField(name) {
return form.querySelector(
'[name="FestivalStallPlacement[' +
name +
']"]'
);
}
function cargoQuery(tableName, fields, where, limit) {
return api.get({
action: 'cargoquery',
tables: tableName,
fields: fields,
where: where,
limit: limit || 50,
format: 'json'
}).then(function (data) {
if (
!data ||
!Array.isArray(data.cargoquery)
) {
return [];
}
return data.cargoquery.map(function (item) {
return item.title || item;
});
});
}
function resolveId(
tableName,
idField,
nameField,
value
) {
if (!value) {
return Promise.resolve(null);
}
if (/^\d+$/.test(value)) {
return Promise.resolve(value);
}
return cargoQuery(
tableName,
idField + '=resolved_id',
nameField +
"='" +
escapeCargo(value) +
"'",
2
).then(function (rows) {
if (rows.length !== 1) {
console.warn(
'IDを一意に取得できません:',
tableName,
value,
rows
);
return null;
}
return String(rows[0].resolved_id);
});
}
function clearWarning() {
warning.hidden = true;
warning.replaceChildren();
}
function showFailure() {
warning.replaceChildren();
const text = document.createElement('div');
text.textContent =
'既存データの確認に失敗しました。' +
'登録はできますが、重複がないかご確認ください。';
warning.appendChild(text);
warning.hidden = false;
}
function showCandidates(rows) {
warning.replaceChildren();
const positionLabels = {
exact: '位置確認済み',
approximate: 'おおよその位置',
unknown: '位置未確認'
};
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 =
'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
rows.length +
'件あります。';
warning.appendChild(title);
const description =
document.createElement('p');
description.className =
'stall-duplicate-warning-description';
description.textContent =
'出店場所が異なる場合は新規登録して構いません。' +
'下の既存データと同じ場所ではないか確認してください。';
warning.appendChild(description);
const list = document.createElement('div');
list.className =
'stall-duplicate-candidate-list';
/*
* placement_id順に並べる
*/
rows.sort(function (a, b) {
return (
Number(a.placement_id) -
Number(b.placement_id)
);
});
rows.forEach(function (row) {
const card =
document.createElement('div');
card.className =
'stall-duplicate-candidate';
/*
* カード見出し
*/
const header =
document.createElement('div');
header.className =
'stall-duplicate-candidate-header';
const heading =
document.createElement('strong');
heading.textContent =
'既存の出店情報';
header.appendChild(heading);
card.appendChild(header);
/*
* 出店場所
*/
addDetail(
card,
'出店場所',
displayValue(
row.location_note,
'場所メモなし'
)
);
/*
* 位置状態
*/
addDetail(
card,
'位置状態',
positionLabels[
row.position_status
] ||
displayValue(
row.position_status,
'位置未確認'
)
);
/*
* 緯度・経度
*/
let coordinates =
'位置情報なし';
if (
row.latitude !== undefined &&
row.latitude !== null &&
String(row.latitude).trim() !== '' &&
row.longitude !== undefined &&
row.longitude !== null &&
String(row.longitude).trim() !== ''
) {
coordinates =
String(row.latitude) +
', ' +
String(row.longitude);
}
addDetail(
card,
'緯度・経度',
coordinates
);
/*
* 位置精度
*/
let accuracy = '未確認';
if (
row.position_accuracy_m !== undefined &&
row.position_accuracy_m !== null &&
String(row.position_accuracy_m).trim() !== ''
) {
accuracy =
String(row.position_accuracy_m) +
' m';
}
addDetail(
card,
'位置精度',
accuracy
);
/*
* 出店状態
*/
addDetail(
card,
'出店状態',
statusLabels[
row.status
] ||
displayValue(
row.status,
'未確認'
)
);
/*
* 最終確認日
*/
let lastConfirmed = '未確認';
if (
row.last_confirmed !== undefined &&
row.last_confirmed !== null &&
String(row.last_confirmed).trim() !== ''
) {
lastConfirmed =
String(row.last_confirmed)
.replace(/-/g, '/');
}
addDetail(
card,
'最終確認日',
lastConfirmed
);
/*
* 確認状態
*/
addDetail(
card,
'確認状態',
verificationLabels[
row.verification_status
] ||
displayValue(
row.verification_status,
'未確認'
)
);
/*
* 既存ページへのリンク
*/
const actions =
document.createElement('div');
actions.className =
'stall-duplicate-candidate-actions';
const link =
document.createElement('a');
link.href =
mw.util.getUrl(row.page_name);
link.target = '_blank';
link.rel = 'noopener';
link.textContent =
'既存データを確認';
actions.appendChild(link);
card.appendChild(actions);
list.appendChild(card);
});
warning.appendChild(list);
const footer =
document.createElement('div');
footer.className =
'stall-duplicate-warning-footer';
footer.textContent =
'同じ場所の場合は新規登録せず、既存データを編集することをおすすめします。';
warning.appendChild(footer);
warning.hidden = false;
}
function checkDuplicates() {
const currentRequest = ++requestId;
/*
* 毎回現在のinput/selectを取得する。
* Page Formsが要素を作り直しても対応できる。
*/
const stall = getField('stall_id');
const festival = getField('festival_id');
const venue = getField('venue_id');
const year = getField('year');
if (
!stall ||
!festival ||
!venue ||
!year
) {
clearWarning();
return;
}
const stallValue = stall.value.trim();
const festivalValue = festival.value.trim();
const venueValue = venue.value.trim();
const yearValue = year.value.trim();
if (
!stallValue ||
!festivalValue ||
!venueValue ||
!/^\d{4}$/.test(yearValue)
) {
clearWarning();
return;
}
/*
* async / await は使わず、
* Promise の then() で処理する。
*/
Promise.all([
resolveId(
'Stalls',
'stall_id',
'name',
stallValue
),
resolveId(
'Festivals',
'festival_id',
'name',
festivalValue
),
resolveId(
'Venues',
'venue_id',
'name',
venueValue
)
])
.then(function (ids) {
if (currentRequest !== requestId) {
return null;
}
if (!ids[0] || !ids[1] || !ids[2]) {
clearWarning();
return null;
}
const where =
'festival_id=' +
ids[1] +
' AND year=' +
yearValue +
' AND venue_id=' +
ids[2] +
' AND stall_id=' +
ids[0];
return cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'location_note=location_note,' +
'latitude=latitude,' +
'longitude=longitude,' +
'position_status=position_status,' +
'position_accuracy_m=position_accuracy_m,' +
'status=status,' +
'verification_status=verification_status,' +
'last_confirmed=last_confirmed,' +
'_pageName=page_name',
where,
50
).then(function (rows) {
if (currentRequest !== requestId) {
return;
}
console.log(
'FestivalStallPlacement候補:',
where,
rows
);
if (rows.length === 0) {
clearWarning();
return;
}
/*
* 今回は警告のみ。
* 同条件の既存データをすべて表示する。
*/
showCandidates(rows);
});
})
.catch(function (error) {
console.error(
'FestivalStallPlacement候補確認エラー:',
error
);
showFailure();
});
}
function scheduleCheck() {
window.clearTimeout(timer);
timer = window.setTimeout(
checkDuplicates,
300
);
}
/*
* form自身へイベントを設定する。
* dropdownが後から置き換わっても拾える。
*/
form.addEventListener('change', function (event) {
const name = event.target.name || '';
if (
name ===
'FestivalStallPlacement[stall_id]' ||
name ===
'FestivalStallPlacement[festival_id]' ||
name ===
'FestivalStallPlacement[venue_id]' ||
name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});
form.addEventListener('input', function (event) {
if (
event.target.name ===
'FestivalStallPlacement[year]'
) {
scheduleCheck();
}
});
scheduleCheck();
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupDuplicateWarning
);
} else {
setupDuplicateWarning();
}
mw.hook('pf.formSetupAfter').add(
setupDuplicateWarning
);
});
/*
* FestivalStallMenuOffering
* 入力検証・日本語表示
*/
(function () {
'use strict';
var FORM_ID = 'pfForm';
var availabilityLabels = {
available: '販売中',
unknown: '未確認'
};
var verificationLabels = {
verified: '確認済み',
partially_verified: '一部確認済み',
unverified: '未確認',
outdated: '情報が古い可能性あり'
};
function isOfferingField(element) {
return !!(
element &&
element.name &&
element.name.indexOf(
'FestivalStallMenuOffering['
) === 0
);
}
function isTemplateField(element) {
return !!(
element &&
element.name &&
element.name.indexOf('[num]') !== -1
);
}
function fieldNameEndsWith(element, suffix) {
return !!(
element &&
element.name &&
element.name.slice(-suffix.length) === suffix
);
}
function localizeSelect(select, labels) {
if (!select) {
return;
}
Array.from(select.options).forEach(
function (option) {
if (
Object.prototype.hasOwnProperty.call(
labels,
option.value
) &&
option.textContent !==
labels[option.value]
) {
option.textContent =
labels[option.value];
}
}
);
}
function validatePrice(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (
value !== '' &&
!/^\d+$/.test(value)
) {
input.setCustomValidity(
'価格は0以上の整数で入力してください(例:600)'
);
}
}
function validateServingQuantity(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (value === '') {
return;
}
if (
!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)
) {
input.setCustomValidity(
'提供数量は0以上の数値で入力してください(例:8、1、0.5)'
);
}
}
function validateLimitedQuantity(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (
value !== '' &&
!/^\d+$/.test(value)
) {
input.setCustomValidity(
'限定数量は0以上の整数で入力してください(例:100)'
);
}
}
function validateSortOrder(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (
value !== '' &&
!/^\d+$/.test(value)
) {
input.setCustomValidity(
'表示順は0以上の整数で入力してください(例:1)'
);
}
}
function validateSourceUrl(input) {
var value = input.value.trim();
input.setCustomValidity('');
if (value === '') {
return;
}
try {
var url = new URL(value);
if (
url.protocol !== 'http:' &&
url.protocol !== 'https:'
) {
input.setCustomValidity(
'情報元URLは http:// または https:// で始まるURLを入力してください。'
);
}
} catch (e) {
input.setCustomValidity(
'情報元URLを正しいURL形式で入力してください。'
);
}
}
function validateLastConfirmed(input) {
var value = input.value;
var max = input.max;
input.setCustomValidity('');
if (
value !== '' &&
max !== '' &&
value > max
) {
input.setCustomValidity(
'未来の日付は入力できません。' +
max.replace(/-/g, '/') +
'以前の日付を入力してください。'
);
}
}
function getVisibleDateInput(dateInput) {
var widget =
dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
return widget.querySelector(
'input[type="text"]'
);
}
function getDateErrorElement(dateInput) {
var widget =
dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
var next =
widget.nextElementSibling;
if (
next &&
next.classList.contains(
'stall-offering-last-confirmed-error'
)
) {
return next;
}
var error =
document.createElement('div');
/*
* 既存の最終確認日エラー用CSSも利用する。
*/
error.className =
'stall-last-confirmed-error ' +
'stall-offering-last-confirmed-error';
error.setAttribute(
'role',
'alert'
);
error.hidden = true;
widget.insertAdjacentElement(
'afterend',
error
);
return error;
}
function showDateError(dateInput) {
var visibleInput =
getVisibleDateInput(dateInput);
var error =
getDateErrorElement(dateInput);
if (!error) {
return;
}
var maxDate =
dateInput.max
? dateInput.max.replace(/-/g, '/')
: '';
if (
dateInput.validity.rangeOverflow ||
(
dateInput.value &&
dateInput.max &&
dateInput.value > dateInput.max
)
) {
error.textContent =
'未来の日付は入力できません。' +
maxDate +
'以前の日付を入力してください。';
} else {
error.textContent =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}
error.hidden = false;
if (visibleInput) {
visibleInput.setAttribute(
'aria-invalid',
'true'
);
}
}
function clearDateError(dateInput) {
var visibleInput =
getVisibleDateInput(dateInput);
var widget =
dateInput.closest('.oo-ui-widget');
var error = null;
if (
widget &&
widget.nextElementSibling &&
widget.nextElementSibling.classList.contains(
'stall-offering-last-confirmed-error'
)
) {
error =
widget.nextElementSibling;
}
if (error) {
error.hidden = true;
error.textContent = '';
}
if (visibleInput) {
visibleInput.removeAttribute(
'aria-invalid'
);
}
}
function getLimitedQuantityInput(
checkbox,
form
) {
if (!checkbox || !checkbox.name) {
return null;
}
var quantityName =
checkbox.name.replace(
/\[limited\]\[value\]$/,
'[limited_quantity]'
);
return Array.from(
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]'
)
).find(
function (input) {
return input.name === quantityName;
}
) || null;
}
function updateLimitedState(
checkbox,
form,
clearWhenOff
) {
var quantityInput =
getLimitedQuantityInput(
checkbox,
form
);
if (!quantityInput) {
return;
}
if (checkbox.checked) {
quantityInput.disabled = false;
quantityInput.removeAttribute(
'aria-disabled'
);
} else {
if (clearWhenOff) {
quantityInput.value = '';
}
quantityInput.setCustomValidity('');
quantityInput.disabled = true;
quantityInput.setAttribute(
'aria-disabled',
'true'
);
}
}
function validateField(element) {
if (
!isOfferingField(element) ||
isTemplateField(element)
) {
return;
}
if (
fieldNameEndsWith(
element,
'[price]'
)
) {
validatePrice(element);
return;
}
if (
fieldNameEndsWith(
element,
'[serving_quantity]'
)
) {
validateServingQuantity(element);
return;
}
if (
fieldNameEndsWith(
element,
'[limited_quantity]'
)
) {
validateLimitedQuantity(element);
return;
}
if (
fieldNameEndsWith(
element,
'[sort_order]'
)
) {
validateSortOrder(element);
return;
}
if (
fieldNameEndsWith(
element,
'[source_url]'
)
) {
validateSourceUrl(element);
return;
}
if (
fieldNameEndsWith(
element,
'[last_confirmed]'
)
) {
validateLastConfirmed(element);
if (element.validity.valid) {
clearDateError(element);
}
return;
}
}
function initializeFields(form) {
/*
* 販売状態を日本語化。
* [num]も変更しておくことで、
* 後から追加されるmultipleにも反映される。
*/
form.querySelectorAll(
'select[name^="FestivalStallMenuOffering["]' +
'[name$="[availability]"]'
).forEach(
function (select) {
localizeSelect(
select,
availabilityLabels
);
}
);
/*
* 確認状態を日本語化。
*/
form.querySelectorAll(
'select[name^="FestivalStallMenuOffering["]' +
'[name$="[verification_status]"]'
).forEach(
function (select) {
localizeSelect(
select,
verificationLabels
);
}
);
/*
* 数値入力向けキーボード。
*/
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[price]"],' +
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[limited_quantity]"],' +
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[sort_order]"]'
).forEach(
function (input) {
input.inputMode = 'numeric';
}
);
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[serving_quantity]"]'
).forEach(
function (input) {
input.inputMode = 'decimal';
}
);
form.querySelectorAll(
'input[name^="FestivalStallMenuOffering["]' +
'[name$="[source_url]"]'
).forEach(
function (input) {
input.inputMode = 'url';
}
);
/*
* 限定数量欄のON/OFF。
*/
form.querySelectorAll(
'input[type="checkbox"]' +
'[name^="FestivalStallMenuOffering["]' +
'[name$="[limited][value]"]'
).forEach(
function (checkbox) {
updateLimitedState(
checkbox,
form,
false
);
}
);
/*
* 現在値を一度検証。
* [num]は除外。
*/
form.querySelectorAll(
'[name^="FestivalStallMenuOffering["]'
).forEach(
function (element) {
validateField(element);
}
);
}
function setupOfferingValidation() {
var form =
document.getElementById(
FORM_ID
);
if (!form) {
return;
}
/*
* wikipage.content 等で再度呼ばれても
* イベントを二重登録しない。
*/
if (
form.dataset
.offeringValidationInitialized ===
'1'
) {
initializeFields(form);
return;
}
form.dataset
.offeringValidationInitialized =
'1';
/*
* multipleで後から追加された項目にも効くよう
* form側でイベント委譲。
*/
form.addEventListener(
'input',
function (event) {
validateField(
event.target
);
}
);
form.addEventListener(
'change',
function (event) {
var target =
event.target;
if (!isOfferingField(target)) {
return;
}
if (
target.type === 'checkbox' &&
fieldNameEndsWith(
target,
'[limited][value]'
)
) {
updateLimitedState(
target,
form,
true
);
}
validateField(target);
}
);
/*
* invalidイベントは通常bubbleしないため
* capture=trueで取得する。
*/
form.addEventListener(
'invalid',
function (event) {
var target =
event.target;
if (
!isOfferingField(target) ||
isTemplateField(target)
) {
return;
}
validateField(target);
if (
fieldNameEndsWith(
target,
'[last_confirmed]'
)
) {
event.preventDefault();
showDateError(target);
var visibleInput =
getVisibleDateInput(
target
);
if (visibleInput) {
window.setTimeout(
function () {
visibleInput.focus();
},
0
);
}
}
},
true
);
/*
* 「販売商品を追加」でDOMが増えた場合の初期化。
*/
var mutationTimer = null;
var observer =
new MutationObserver(
function () {
window.clearTimeout(
mutationTimer
);
mutationTimer =
window.setTimeout(
function () {
initializeFields(
form
);
},
100
);
}
);
observer.observe(
form,
{
childList: true,
subtree: true
}
);
initializeFields(form);
}
if (
document.readyState ===
'loading'
) {
document.addEventListener(
'DOMContentLoaded',
setupOfferingValidation
);
} else {
setupOfferingValidation();
}
mw.hook(
'wikipage.content'
).add(
setupOfferingValidation
);
mw.hook(
'pf.formSetupAfter'
).add(
setupOfferingValidation
);
})();
mw.loader.using('mediawiki.api').then(function () {
'use strict';
if (window.__festivalStallMenuFilterInitialized) {
return;
}
window.__festivalStallMenuFilterInitialized = true;
const STALL_SELECTOR =
'select[name="FestivalStallPlacement[stall_id]"]';
const MENU_SELECTOR =
'select[name^="FestivalStallMenuOffering["][name$="[menu_item_id]"]';
const TEMPLATE_MENU_SELECTOR =
'select[name="FestivalStallMenuOffering[num][menu_item_id]"]';
const api = new mw.Api();
let requestSerial = 0;
let observerTimer = null;
let applying = false;
const menuCache = {};
/*
* FestivalStallPlacement フォーム以外では
* この連動機能を起動しない。
*/
const stallSelect =
document.querySelector(STALL_SELECTOR);
if (!stallSelect) {
return;
}
/*
* Page Formsの雛形が持つ全商品optionを最初に保存
*/
const templateSelect =
document.querySelector(TEMPLATE_MENU_SELECTOR);
if (!templateSelect) {
console.error(
'販売商品の雛形SELECTが見つかりません。'
);
return;
}
const masterOptions =
[...templateSelect.options].map(
function (option) {
return option.cloneNode(true);
}
);
function cargoQuote(value) {
return "'" + String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'") + "'";
}
function cargoRows(res) {
return (res.cargoquery || []).map(
function (row) {
return row.title || {};
}
);
}
function getRealMenuSelects() {
return [
...document.querySelectorAll(
MENU_SELECTOR
)
].filter(function (select) {
return !select.name.includes('[num]');
});
}
function resolveStallId(stallName) {
return api.get({
action: 'cargoquery',
format: 'json',
tables: 'Stalls',
fields:
'stall_id=stall_id,' +
'name=name',
where:
'name=' +
cargoQuote(stallName),
limit: 20
}).then(function (res) {
const rows =
cargoRows(res);
if (rows.length === 1) {
return rows[0].stall_id;
}
/*
* 同名表示が
* 名前 (ID)
* になっている場合
*/
const match =
String(stallName)
.match(/\((\d+)\)$/);
if (!match) {
throw new Error(
'屋台を1件に特定できません: ' +
stallName
);
}
return match[1];
});
}
function loadMenus(stallId) {
const key =
String(stallId);
if (menuCache[key]) {
return Promise.resolve(
menuCache[key]
);
}
return api.get({
action: 'cargoquery',
format: 'json',
tables: 'StallMenuItems',
fields:
'menu_item_id=menu_item_id,' +
'stall_id=stall_id,' +
'name=name,' +
'status=status',
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 ||
!stall.value
) {
return;
}
const serial =
++requestSerial;
const stallName =
stall.value;
resolveStallId(
stallName
)
.then(function (stallId) {
if (
serial !==
requestSerial
) {
return null;
}
console.log(
'[屋台→商品V2]',
stallName,
'→ stall_id=' +
stallId
);
return loadMenus(
stallId
);
})
.then(function (menus) {
if (
!menus ||
serial !==
requestSerial
) {
return;
}
console.log(
'[販売商品候補V2]',
menus
);
filterMenuSelects(
menus,
clearSelection
);
})
.catch(function (err) {
console.error(
'[屋台→商品V2] エラー:',
err
);
});
}
/*
* Page Formsによる
* option再生成を検出
*/
function mutationTouchesMenus(
mutation
) {
const target =
mutation.target;
if (
target.nodeType === 1 &&
target.matches &&
target.matches(MENU_SELECTOR)
) {
return true;
}
for (
const node of
mutation.addedNodes
) {
if (
node.nodeType !== 1
) {
continue;
}
if (
node.matches &&
node.matches(MENU_SELECTOR)
) {
return true;
}
if (
node.querySelector &&
node.querySelector(
MENU_SELECTOR
)
) {
return true;
}
/*
* SELECTの中にOPTIONが追加された
*/
if (
node.tagName === 'OPTION' &&
node.parentElement &&
node.parentElement.matches &&
node.parentElement.matches(
MENU_SELECTOR
)
) {
return true;
}
}
return false;
}
const observer =
new MutationObserver(
function (mutations) {
if (applying) {
return;
}
const touched =
mutations.some(
mutationTouchesMenus
);
if (!touched) {
return;
}
clearTimeout(
observerTimer
);
/*
* Page Formsの再初期化が
* 完了してから実行
*/
observerTimer =
setTimeout(
function () {
refreshMenus(false);
},
250
);
}
);
const form =
document.getElementById(
'pfForm'
) || document.body;
observer.observe(
form,
{
childList: true,
subtree: true
}
);
/*
* 屋台変更
*/
const stall =
document.querySelector(
STALL_SELECTOR
);
function onStallChange() {
refreshMenus(true);
}
stall.addEventListener(
'change',
onStallChange
);
/*
* 初期表示
*/
refreshMenus(false);
console.log(
'屋台→販売商品連動を初期化しました。'
);
});
/*
* StallMenuItem
* 入力検証・状態日本語化
*/
(function () {
'use strict';
function setupStallMenuItemValidation() {
const form = document.getElementById('pfForm');
if (!form) {
return;
}
/*
* StallMenuItemフォーム以外では何もしない。
*/
const nameInput = form.querySelector(
'input[name="StallMenuItem[name]"]'
);
if (!nameInput) {
return;
}
/*
* 二重初期化防止
*/
if (
form.dataset.stallMenuItemValidationInitialized === '1'
) {
return;
}
form.dataset.stallMenuItemValidationInitialized = '1';
/*
* =====================================
* 状態を日本語表示
* =====================================
*/
const statusLabels = {
active: '取扱中',
inactive: '一時停止',
discontinued: '取扱終了',
unknown: '未確認'
};
const statusSelect = form.querySelector(
'select[name="StallMenuItem[status]"]'
);
if (statusSelect) {
Array.from(statusSelect.options).forEach(
function (option) {
if (statusLabels[option.value]) {
option.textContent =
statusLabels[option.value];
}
}
);
}
console.log(
'商品マスター入力チェックを初期化しました。'
);
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupStallMenuItemValidation
);
} else {
setupStallMenuItemValidation();
}
mw.hook('wikipage.content').add(
setupStallMenuItemValidation
);
})();
/*
* StallMenuItem
* 同一屋台 + 同一商品名の重複警告
*
* 保存は禁止しない。
*/
mw.loader.using([
'mediawiki.api',
'mediawiki.util'
]).then(function () {
'use strict';
const api = new mw.Api();
function cargoQuote(value) {
return "'" + String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'") + "'";
}
function cargoRows(response) {
return (response.cargoquery || []).map(
function (row) {
return row.title || row;
}
);
}
function cargoQuery(
tables,
fields,
where,
limit
) {
return api.get({
action: 'cargoquery',
format: 'json',
tables: tables,
fields: fields,
where: where,
limit: String(limit || 20)
}).then(
function (response) {
return cargoRows(response);
}
);
}
function normalizePageName(value) {
return String(value || '')
.replace(/_/g, ' ')
.trim();
}
function setupStallMenuItemDuplicateWarning() {
const form =
document.getElementById('pfForm');
if (!form) {
return;
}
/*
* StallMenuItemフォームだけを対象にする。
*/
const stallSelect = form.querySelector(
'select[name="StallMenuItem[stall_id]"]'
);
const nameInput = form.querySelector(
'input[name="StallMenuItem[name]"]'
);
if (!stallSelect || !nameInput) {
return;
}
/*
* 二重初期化防止
*/
if (
form.dataset
.stallMenuItemDuplicateWarning ===
'1'
) {
return;
}
form.dataset
.stallMenuItemDuplicateWarning =
'1';
/*
* 警告表示欄
*/
const warning =
document.createElement('div');
warning.className =
'stall-menu-item-duplicate-warning';
warning.setAttribute(
'role',
'status'
);
warning.hidden = true;
warning.style.marginTop = '8px';
warning.style.padding = '10px';
warning.style.border = '1px solid #a2a9b1';
warning.style.borderRadius = '4px';
const container =
nameInput.closest('td') ||
nameInput.parentNode;
container.appendChild(warning);
let timer = null;
let requestSerial = 0;
/*
* Page Formsのmappingでは
* SELECT.valueが屋台名になる場合があるため、
* Cargoからstall_idを解決する。
*/
function resolveStallId() {
const rawValue =
String(
stallSelect.value || ''
).trim();
if (!rawValue) {
return Promise.resolve('');
}
/*
* 数値ならそのまま使用。
*/
if (/^\d+$/.test(rawValue)) {
return Promise.resolve(
rawValue
);
}
const selectedOption =
stallSelect.options[
stallSelect.selectedIndex
];
const selectedText =
selectedOption
? selectedOption.textContent.trim()
: '';
const names = [];
if (rawValue) {
names.push(rawValue);
}
if (
selectedText &&
names.indexOf(selectedText) === -1
) {
names.push(selectedText);
}
if (names.length === 0) {
return Promise.resolve('');
}
const where = names.map(
function (name) {
return (
'name=' +
cargoQuote(name)
);
}
).join(' OR ');
return cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name',
where,
10
).then(
function (rows) {
if (!rows.length) {
return '';
}
return String(
rows[0].stall_id || ''
);
}
);
}
function clearWarning() {
warning.hidden = true;
warning.textContent = '';
}
function showWarning(rows) {
warning.textContent = '';
const title =
document.createElement('strong');
title.textContent =
'同じ屋台に同名の商品がすでに登録されています。';
warning.appendChild(title);
const text =
document.createElement('div');
text.textContent =
'重複登録でないか既存商品を確認してください。保存自体は禁止しません。';
warning.appendChild(text);
const list =
document.createElement('ul');
rows.forEach(
function (row) {
const item =
document.createElement('li');
const link =
document.createElement('a');
link.href =
mw.util.getUrl(
row.page_name
);
link.textContent =
(
row.menu_name ||
'商品'
) +
'(商品ID: ' +
row.menu_item_id +
')';
link.target = '_blank';
item.appendChild(link);
list.appendChild(item);
}
);
warning.appendChild(list);
warning.hidden = false;
}
function checkDuplicate() {
const menuName =
nameInput.value.trim();
if (
!stallSelect.value ||
!menuName
) {
clearWarning();
return;
}
const currentRequest =
++requestSerial;
resolveStallId().then(
function (stallId) {
if (
currentRequest !==
requestSerial
) {
return null;
}
if (!stallId) {
clearWarning();
return null;
}
return cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'name=menu_name,' +
'_pageName=page_name',
'stall_id=' +
stallId +
' AND name=' +
cargoQuote(
menuName
),
20
);
}
).then(
function (rows) {
if (
rows === null ||
rows === undefined
) {
return;
}
if (
currentRequest !==
requestSerial
) {
return;
}
/*
* 編集画面では
* 自分自身を重複候補から除外。
*/
const currentPage =
normalizePageName(
mw.config.get(
'wgPageName'
)
);
const duplicates =
rows.filter(
function (row) {
return (
normalizePageName(
row.page_name
) !==
currentPage
);
}
);
if (
duplicates.length === 0
) {
clearWarning();
return;
}
showWarning(
duplicates
);
}
).catch(
function (error) {
console.error(
'商品重複確認に失敗しました。',
error
);
clearWarning();
}
);
}
function scheduleCheck() {
window.clearTimeout(timer);
timer =
window.setTimeout(
checkDuplicate,
300
);
}
stallSelect.addEventListener(
'change',
scheduleCheck
);
nameInput.addEventListener(
'input',
scheduleCheck
);
nameInput.addEventListener(
'change',
scheduleCheck
);
/*
* 編集画面で既存値が入っている場合にも確認。
*/
scheduleCheck();
console.log(
'商品重複警告を初期化しました。'
);
}
if (
document.readyState ===
'loading'
) {
document.addEventListener(
'DOMContentLoaded',
setupStallMenuItemDuplicateWarning
);
} else {
setupStallMenuItemDuplicateWarning();
}
mw.hook(
'wikipage.content'
).add(
setupStallMenuItemDuplicateWarning
);
});