// <nowiki> let osmWindowManager; function getOSMWindowManager() { if (!osmWindowManager) { osmWindowManager = new OO.ui.WindowManager(); $(document.body).append(osmWindowManager.$element); } return osmWindowManager; } const OVERPASS_ENDPOINTS = [ 'https://overpass-api.de/api/interpreter', 'https://lz4.overpass-api.de/api/interpreter', 'https://z.overpass-api.de/api/interpreter', 'https://overpass.openstreetmap.fr/api/interpreter' ]; mw.loader.using([ 'mediawiki.util', 'mediawiki.api', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows', 'jquery.textSelection' ]).then(function () { if (mw.config.get('wgNamespaceNumber') !== 0) return; const itemId = mw.config.get('wgWikibaseItemId'); const pageTitle = mw.config.get('wgTitle'); const portletLink = mw.util.addPortletLink( 'p-cactions', '#', 'Fetch OSM listings', 'ca-osmfetch', 'Fetch travel listings from OpenStreetMap' ); if (!portletLink) return; $(portletLink).on('click', async function (e) { e.preventDefault(); if (!itemId) { mw.notify('Error: This article is not linked to a Wikidata item yet. Please link it in the sidebar first.', { title: 'OSMFetch', type: 'error', autoHide: false }); return; } mw.notify('Checking location data...', { title: 'OSMFetch' }); const locationMeta = await fetchWikidataEntityData(itemId); openConfirmDialog(itemId, pageTitle, locationMeta); }); }); async function fetchWikidataEntityData(itemId) { const result = { is12Hour: false, coords: null, osmRelId: null }; try { const localData = await new mw.Api().get({ action: 'query', prop: 'coordinates', titles: mw.config.get('wgPageName'), formatversion: 2 }); const pages = localData.query && localData.query.pages; if (pages && pages[0] && pages[0].coordinates && pages[0].coordinates[0]) { result.coords = { lat: pages[0].coordinates[0].lat, lon: pages[0].coordinates[0].lon }; } } catch (e) {} try { const res = await fetch(`https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${itemId}&props=claims&format=json&origin=*`); const data = await res.json(); const claims = data.entities && data.entities[itemId] && data.entities[itemId].claims; if (claims) { if (claims.P17 && claims.P17.length) { const countryId = claims.P17[0].mainsnak.datavalue.value.id; const qIds12h = [ 'Q16', 'Q30', 'Q240', 'Q792', 'Q800', 'Q804', 'Q739', 'Q786', 'Q778', 'Q5785', 'Q23635', 'Q145', 'Q27', 'Q79', 'Q1014', 'Q1044', 'Q1033', 'Q817', 'Q846', 'Q398', 'Q878', 'Q842', 'Q843', 'Q668', 'Q902', 'Q854', 'Q8646', 'Q14773', 'Q928', 'Q833', 'Q921', 'Q334', 'Q691', 'Q408', 'Q664', 'Q712', 'Q697', 'Q672', 'Q710' ]; result.is12Hour = qIds12h.includes(countryId); } if (!result.coords && claims.P625 && claims.P625.length) { const val = claims.P625[0].mainsnak.datavalue.value; result.coords = { lat: val.latitude, lon: val.longitude }; } if (claims.P402 && claims.P402.length) { result.osmRelId = claims.P402[0].mainsnak.datavalue.value; } } } catch (e) {} return result; } function getDistanceMeters(lat1, lon1, lat2, lon2) { const R = 6371000; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } function findExistingListingInWikitext(wikitext, targetName, targetLat, targetLon, matchedIndices) { const regex = /\{\{\s*(see|do|buy|eat|drink|sleep|listing)\b([\s\S]*?)\}\}/gi; let match; while ((match = regex.exec(wikitext)) !== null) { if (matchedIndices && matchedIndices.has(match.index)) continue; const fullTemplate = match[0]; const body = match[2]; const nameMatch = body.match(/\|\s*name\s*=\s*([^|}]+)/i); const tName = nameMatch ? nameMatch[1].trim() : ''; const latMatch = body.match(/\|\s*lat\s*=\s*(-?\d+(?:\.\d+)?)/i); const lonMatch = body.match(/\|\s*(?:long|lon)\s*=\s*(-?\d+(?:\.\d+)?)/i); const tLat = latMatch ? parseFloat(latMatch[1]) : null; const tLon = lonMatch ? parseFloat(lonMatch[1]) : null; const cleanTName = tName.toLowerCase().replace(/^(the|a|an)\s+/i, '').trim(); const cleanTargetName = targetName.toLowerCase().replace(/^(the|a|an)\s+/i, '').trim(); const isNameMatch = cleanTName && cleanTargetName && (cleanTName === cleanTargetName || cleanTName.includes(cleanTargetName) || cleanTargetName.includes(cleanTName)); let isCoordMatch = false; if (targetLat && targetLon && tLat !== null && tLon !== null) { const itemLat = parseFloat(targetLat); const itemLon = parseFloat(targetLon); if (!isNaN(itemLat) && !isNaN(itemLon)) { if (getDistanceMeters(itemLat, itemLon, tLat, tLon) <= 35) { isCoordMatch = true; } } } let isMatch = false; let matchType = ''; if (isNameMatch) { isMatch = true; matchType = 'name'; } else if (isCoordMatch) { const tWords = cleanTName.split(/\s+/).filter(w => w.length > 2); const targetWords = cleanTargetName.split(/\s+/).filter(w => w.length > 2); const hasCommonWord = tWords.some(w => targetWords.includes(w)); if (hasCommonWord || cleanTName.length < 3 || cleanTargetName.length < 3) { isMatch = true; matchType = 'nearby location with similar name'; } } if (isMatch) { return { fullTemplate: fullTemplate, index: match.index, matchedName: tName || targetName, matchType: matchType }; } } return null; } function mergeListingIntoWikitext(templateWikitext, item) { let updated = templateWikitext; const isMultiLine = templateWikitext.includes('\n'); const details = item.details; const fieldsToMerge = [ { mainKey: 'address', keyPattern: 'address', val: details.address }, { mainKey: 'lat', keyPattern: 'lat', val: item.lat }, { mainKey: 'long', keyPattern: 'long|lon', val: item.lon }, { mainKey: 'url', keyPattern: 'url|website', val: details.url }, { mainKey: 'email', keyPattern: 'email', val: details.email }, { mainKey: 'phone', keyPattern: 'phone', val: details.phone }, { mainKey: 'hours', keyPattern: 'hours', val: details.hours }, { mainKey: 'price', keyPattern: 'price', val: details.price }, { mainKey: 'content', keyPattern: 'content|description', val: details.content } ]; const filledFields = []; fieldsToMerge.forEach(({ mainKey, keyPattern, val }) => { if (!val) return; const emptyParamRegex = new RegExp(`(\\|\\s*(?:${keyPattern})\\s*=)[ \\t]*(?=[\\|\\}\\r\\n])`, 'i'); if (emptyParamRegex.test(updated)) { updated = updated.replace(emptyParamRegex, (match, p1, offset, string) => { const after = string.slice(offset + match.length); const isEndOfLine = /^[\r\n]/.test(after); return isEndOfLine ? `${p1} ${val}` : `${p1} ${val} `; }); filledFields.push(mainKey); } else { const existsRegex = new RegExp(`\\|\\s*(?:${keyPattern})\\s*=`, 'i'); if (!existsRegex.test(updated)) { if (isMultiLine) { updated = updated.replace(/\s*\}\}\s*$/, `\n| ${mainKey}=${val}\n}}`); } else { updated = updated.replace(/\s*\}\}\s*$/, ` | ${mainKey}=${val} }}`); } filledFields.push(mainKey); } } }); if (filledFields.length > 0 && item.lastedit) { const lasteditRegex = /(\|\s*lastedit\s*=)[ \t]*([^|}\r\n]*)/i; if (lasteditRegex.test(updated)) { updated = updated.replace(lasteditRegex, (match, p1, p2, offset, string) => { const after = string.slice(offset + match.length); const isEndOfLine = /^[\r\n]/.test(after); return isEndOfLine ? `${p1} ${item.lastedit}` : `${p1} ${item.lastedit} `; }); } else { if (isMultiLine) { updated = updated.replace(/\s*\}\}\s*$/, `\n| lastedit=${item.lastedit}\n}}`); } else { updated = updated.replace(/\s*\}\}\s*$/, ` | lastedit=${item.lastedit} }}`); } } } return { updatedTemplate: updated, filledFields: filledFields }; } function formatCoord(coord) { if (coord === undefined || coord === null || coord === '') return ''; const num = Number(coord); return isNaN(num) ? '' : num.toFixed(5); } function formatOSMDate(isoDate) { if (!isoDate) return ''; const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const parts = isoDate.split('-'); if (parts.length !== 3) return isoDate; return `${parts[2]} ${months[parseInt(parts[1], 10) - 1]} ${parts[0]}`; } function formatAddress(address) { if (!address) return ''; return address .replace(/\bStreet\b/gi, 'St') .replace(/\bRoad\b/gi, 'Rd') .replace(/\bAvenue\b/gi, 'Av') .replace(/\bBoulevard\b/gi, 'Bvd') .replace(/\bHighway\b/gi, 'Hwy') .replace(/\bParkway\b/gi, 'Pkwy') .replace(/\bLane\b/gi, 'La') .replace(/\bDrive\b/gi, 'Dr') .replace(/\bCourt\b/gi, 'Ct') .replace(/\bPlace\b/gi, 'Pl') .replace(/\bSquare\b/gi, 'Sq'); } function formatOSMHours(osmHours, use12Hour) { if (!osmHours) return ''; if (osmHours.trim() === '24/7') return '24 hours'; let parsed = osmHours .replace(/Mo-Su/g, 'Daily') .replace(/Mo/g, 'Mon') .replace(/Tu/g, 'Tue') .replace(/We/g, 'Wed') .replace(/Th/g, 'Thu') .replace(/Fr/g, 'Fri') .replace(/Sa/g, 'Sat') .replace(/Su/g, 'Sun') .replace(/PH/g, 'public holidays'); const twoDays = ['Mon-Tue', 'Tue-Wed', 'Wed-Thu', 'Thu-Fri', 'Fri-Sat', 'Sat-Sun', 'Sun-Mon']; twoDays.forEach(pair => { parsed = parsed.replace(new RegExp(pair, 'g'), pair.replace('-', ' ')); }); parsed = parsed.replace(/([A-Z][a-z]{2})-([A-Z][a-z]{2})/g, '$1–$2'); parsed = parsed.replace(/([A-Z][a-z]{2}),\s*([A-Z][a-z]{2})/g, '$1 $2'); parsed = parsed.replace(/([A-Z][a-z]{2}),\s*([A-Z][a-z]{2})/g, '$1 $2'); parsed = parsed.replace(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/g, (match, t1, t2) => { if (use12Hour) { const to12 = (time) => { let [h, m] = time.split(':').map(Number); if (h === 0 && m === 0) return 'midnight'; if (h === 12 && m === 0) return 'noon'; let ampm = h >= 12 ? 'PM' : 'AM'; let h12 = h % 12 || 12; return m === 0 ? `${h12}${ampm}` : `${h12}:${m.toString().padStart(2, '0')}${ampm}`; }; return `${to12(t1)}–${to12(t2)}`; } else { return `${t1}–${t2}`; } }); parsed = parsed.replace(/\boff\b/gi, 'closed'); return parsed; } function generateCategoryStatements(categories, spatialFilter) { let queries = []; if (categories.includes('see')) { queries.push(`node["tourism"~"museum|gallery|viewpoint|zoo|aquarium"]${spatialFilter};`); queries.push(`way["tourism"~"museum|gallery|viewpoint|zoo|aquarium"]${spatialFilter};`); queries.push(`node["historic"~"monument|ruins|castle|archaeological_site|memorial"]${spatialFilter};`); queries.push(`way["historic"~"monument|ruins|castle|archaeological_site|memorial"]${spatialFilter};`); queries.push(`node["man_made"="lighthouse"]${spatialFilter};`); queries.push(`way["man_made"="lighthouse"]${spatialFilter};`); } if (categories.includes('do')) { queries.push(`node["amenity"~"theatre|cinema|arts_centre|events_venue"]${spatialFilter};`); queries.push(`way["amenity"~"theatre|cinema|arts_centre|events_venue"]${spatialFilter};`); queries.push(`node["leisure"~"park|sports_centre|stadium|swimming_pool|water_park|theme_park"]${spatialFilter};`); queries.push(`way["leisure"~"park|sports_centre|stadium|swimming_pool|water_park|theme_park"]${spatialFilter};`); } if (categories.includes('buy')) { queries.push(`node["shop"~"clothes|supermarket|mall|gift|bakery|books|convenience"]${spatialFilter};`); queries.push(`way["shop"~"clothes|supermarket|mall|gift|bakery|books|convenience"]${spatialFilter};`); } if (categories.includes('eat')) { queries.push(`node["amenity"~"restaurant|cafe|fast_food|food_court"]${spatialFilter};`); queries.push(`way["amenity"~"restaurant|cafe|fast_food|food_court"]${spatialFilter};`); } if (categories.includes('drink')) { queries.push(`node["amenity"~"bar|pub|nightclub|biergarten"]${spatialFilter};`); queries.push(`way["amenity"~"bar|pub|nightclub|biergarten"]${spatialFilter};`); } if (categories.includes('sleep')) { queries.push(`node["tourism"~"hotel|hostel|guest_house|motel|alpine_hut|camp_site|campsite|chalet"]${spatialFilter};`); queries.push(`way["tourism"~"hotel|hostel|guest_house|motel|alpine_hut|camp_site|campsite|chalet"]${spatialFilter};`); } return queries; } function openConfirmDialog(wikidataId, pageTitle, locationMeta) { function OSMConfirmDialog(config) { OSMConfirmDialog.super.call(this, config); } OO.inheritClass(OSMConfirmDialog, OO.ui.ProcessDialog); OSMConfirmDialog.static.name = 'osmConfirmDialog'; OSMConfirmDialog.static.title = 'Confirm OSM fetch'; OSMConfirmDialog.static.actions = [ { action: 'fetch', label: 'Fetch listings', flags: ['primary', 'progressive'] }, { label: 'Cancel', flags: 'safe' } ]; OSMConfirmDialog.prototype.initialize = function () { OSMConfirmDialog.super.prototype.initialize.call(this); this.content = new OO.ui.PanelLayout({ padded: true, expanded: false }); const boundaryInfo = locationMeta.osmRelId ? ` | OSM relation: <code>${locationMeta.osmRelId}</code>` : ''; const textLabel = $('<p>').html(`Select the listing types to fetch for <strong>${pageTitle}</strong>:<br> <small>Wikidata area ID: <code>${wikidataId}</code>${boundaryInfo}</small><br><br> <span style="color: #d33; font-size: 0.9em;"><strong>Warning:</strong> Ticking more categories will result in longer loading times as the Overpass server queries complex boundaries.</span>`); const categoryOptions = [ { data: 'see', label: 'See' }, { data: 'do', label: 'Do' }, { data: 'buy', label: 'Buy' }, { data: 'eat', label: 'Eat' }, { data: 'drink', label: 'Drink' }, { data: 'sleep', label: 'Sleep' } ].map(cat => new OO.ui.CheckboxMultioptionWidget({ data: cat.data, label: cat.label, selected: false })); this.categoryCheckboxes = new OO.ui.CheckboxMultiselectWidget({ items: categoryOptions }); this.clockCheckbox = new OO.ui.CheckboxInputWidget({ selected: locationMeta.is12Hour }); const clockField = new OO.ui.FieldLayout(this.clockCheckbox, { label: 'Format opening hours using 12-hour clock (AM/PM)', align: 'inline' }); clockField.$element.css('margin-top', '1em'); const disclaimerNotice = new OO.ui.MessageWidget({ type: 'warning', inline: true, label: new OO.ui.HtmlSnippet('<strong>Privacy & licensing notice:</strong> This tool executes requests directly from your browser to third-party OpenStreetMap Overpass servers. Your IP address is visible to Overpass server operators and stored in their rate-limiting and query logs. All imported listings are licensed under the Open Database License (ODbL) and must be reviewed for accuracy.') }); disclaimerNotice.$element.css('margin-top', '1.2em'); this.disclaimerCheckbox = new OO.ui.CheckboxInputWidget({ selected: false }); const disclaimerField = new OO.ui.FieldLayout(this.disclaimerCheckbox, { label: new OO.ui.HtmlSnippet('<span>I understand that this tool contacts third-party servers directly (revealing my IP address) and that I am responsible for reviewing imported data.</span>'), align: 'inline' }); disclaimerField.$element.css('margin-top', '0.8em'); this.disclaimerCheckbox.on('change', (selected) => { this.actions.setAbilities({ fetch: selected }); }); this.content.$element.append( textLabel, $('<br>'), this.categoryCheckboxes.$element, clockField.$element, disclaimerNotice.$element, disclaimerField.$element ); this.$body.append(this.content.$element); }; OSMConfirmDialog.prototype.getSetupProcess = function (data) { return OSMConfirmDialog.super.prototype.getSetupProcess.call(this, data).next(() => { this.actions.setAbilities({ fetch: false }); }); }; OSMConfirmDialog.prototype.getActionProcess = function (action) { const dialog = this; if (action === 'fetch') { if (!this.disclaimerCheckbox.isSelected()) { mw.notify('Please acknowledge the disclaimer before proceeding.', { type: 'error' }); return new OO.ui.Process(); } const selectedCategories = this.categoryCheckboxes.findSelectedItemsData(); const use12Hour = this.clockCheckbox.isSelected(); if (selectedCategories.length === 0) { mw.notify('Please select at least one category.', { type: 'error' }); return new OO.ui.Process(); } return new OO.ui.Process(function () { dialog.pushPending(); return doOverpassFetch(wikidataId, selectedCategories, use12Hour, locationMeta).then(listings => { dialog.close(); if (listings && listings.length > 0) { openOSMSelectionDialog(listings); } else { mw.notify('No suitable listings found in this area for the selected categories.', { type: 'warn' }); } }).catch(error => { dialog.popPending(); dialog.showErrors(new OO.ui.Error(error.message)); }); }); } return OSMConfirmDialog.super.prototype.getActionProcess.call(this, action); }; const windowManager = getOSMWindowManager(); const dialog = new OSMConfirmDialog({ size: 'medium' }); windowManager.addWindows([dialog]); windowManager.openWindow(dialog); } async function fetchFromOverpassEndpoints(overpassQuery, startIndex = 0) { let lastError = null; for (let i = 0; i < OVERPASS_ENDPOINTS.length; i++) { const endpoint = OVERPASS_ENDPOINTS[(startIndex + i) % OVERPASS_ENDPOINTS.length]; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(new Error('Overpass query timed out after 30 seconds.')), 30000); try { const response = await fetch(endpoint, { method: 'POST', body: 'data=' + encodeURIComponent(overpassQuery), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, signal: controller.signal }); clearTimeout(timeoutId); if (response.status === 429) { await new Promise(r => setTimeout(r, 2000)); throw new Error('HTTP 429: Too Many Requests'); } if (!response.ok) { const errText = await response.text().catch(() => ''); throw new Error(`HTTP ${response.status}: ${errText.substring(0, 80)}`); } const data = await response.json(); if (data && Array.isArray(data.elements)) { return data.elements; } throw new Error('Invalid JSON response format from Overpass.'); } catch (err) { clearTimeout(timeoutId); lastError = err; await new Promise(r => setTimeout(r, 1000)); } } throw lastError || new Error('All Overpass API endpoints failed.'); } async function doOverpassFetch(wikidataId, categories, use12Hour, locationMeta) { let elements = []; const areaStatements = generateCategoryStatements(categories, '(area.searchArea)'); if (locationMeta.osmRelId) { const relNum = parseInt(locationMeta.osmRelId, 10); if (!isNaN(relNum) && relNum > 0) { const indexedAreaQuery = `[out:json][timeout:25]; area(${3600000000 + relNum})->.searchArea; ( ${areaStatements.join('\n ')} ); out center meta;`; try { elements = await fetchFromOverpassEndpoints(indexedAreaQuery, 0); } catch (err) { elements = []; } if (!elements || elements.length === 0) { const mapToAreaQuery = `[out:json][timeout:25]; rel(${relNum}); map_to_area->.searchArea; ( ${areaStatements.join('\n ')} ); out center meta;`; try { elements = await fetchFromOverpassEndpoints(mapToAreaQuery, 0); } catch (err) { elements = []; } } } } else { const relLookupQuery = `[out:json][timeout:25]; ( rel["wikidata"="${wikidataId}"]["boundary"]; way["wikidata"="${wikidataId}"]["boundary"]; ); map_to_area->.searchArea; ( ${areaStatements.join('\n ')} ); out center meta;`; try { elements = await fetchFromOverpassEndpoints(relLookupQuery, 0); } catch (err) { elements = []; } } if (!elements || elements.length === 0) { if (locationMeta.coords) { mw.notify('Boundary yielded no results. Searching within 5 km radius...', { title: 'OSMFetch' }); const radiusFilter = `(around:5000,${locationMeta.coords.lat},${locationMeta.coords.lon})`; const radiusStatements = generateCategoryStatements(categories, radiusFilter); const radiusQuery = `[out:json][timeout:25]; ( ${radiusStatements.join('\n ')} ); out center meta;`; elements = await fetchFromOverpassEndpoints(radiusQuery, 1); } } if (!elements || elements.length === 0) { return []; } return parseOSMData(elements, use12Hour); } function parseOSMData(elements, use12Hour) { const listings = []; elements.forEach(el => { const tags = el.tags || {}; if (!tags.name) return; let type = ''; if (tags.shop) { type = 'buy'; } else if (tags.amenity) { if (/restaurant|cafe|fast_food|food_court/.test(tags.amenity)) type = 'eat'; else if (/bar|pub|nightclub|biergarten/.test(tags.amenity)) type = 'drink'; else if (/theatre|cinema|arts_centre|events_venue/.test(tags.amenity)) type = 'do'; } else if (tags.tourism) { if (/hotel|hostel|guest_house|motel|alpine_hut|camp_site|campsite|chalet/.test(tags.tourism)) type = 'sleep'; else if (/museum|gallery|viewpoint|zoo|aquarium/.test(tags.tourism)) type = 'see'; } else if (tags.historic) { type = 'see'; } else if (tags.man_made === 'lighthouse') { type = 'see'; } else if (tags.leisure) { type = 'do'; } if (!type) return; const lat = formatCoord(el.lat !== undefined ? el.lat : el.center?.lat); const lon = formatCoord(el.lon !== undefined ? el.lon : el.center?.lon); let address = tags['addr:street'] ? `${tags['addr:housenumber'] || ''} ${tags['addr:street']}`.trim() : ''; address = formatAddress(address); const website = tags.website || tags['contact:website'] || tags.url || ''; const phone = tags.phone || tags['contact:phone'] || ''; const email = tags.email || tags['contact:email'] || ''; const lastedit = el.timestamp ? el.timestamp.substring(0, 10) : ''; const formattedHours = formatOSMHours(tags.opening_hours, use12Hour); let price = ''; if (tags.fee === 'no' || tags.charge === 'no') { price = 'Free'; } let contentParts = []; if ((type === 'eat' || type === 'drink') && tags.cuisine) { const formattedCuisine = tags.cuisine .split(';') .map(c => c.trim()) .filter(Boolean) .map(c => c.charAt(0).toUpperCase() + c.slice(1)) .join(', '); if (formattedCuisine) contentParts.push(formattedCuisine); } if (tags.description) { contentParts.push(tags.description.trim()); } const content = contentParts.join('. ').replace(/\.\./g, '.'); const foundFields = []; if (address) foundFields.push('address'); if (website) foundFields.push('website'); if (phone) foundFields.push('phone'); if (email) foundFields.push('email'); if (formattedHours) foundFields.push('hours'); if (price) foundFields.push('price'); if (content) foundFields.push('description'); const template = `* {{${type} | name=${tags.name} | alt= | url=${website} | email=${email} | address=${address} | lat=${lat} | long=${lon} | directions= | phone=${phone} | tollfree= | fax= | hours=${formattedHours} | price=${price} | checkin= | checkout= | content=${content} | lastedit=${lastedit} }}`; listings.push({ name: tags.name, type: type, wikitext: template, fields: foundFields, lastedit: lastedit, lasteditFormatted: formatOSMDate(lastedit), lat: lat, lon: lon, details: { address: address, url: website, phone: phone, email: email, hours: formattedHours, price: price, content: content, lastedit: lastedit } }); }); return listings; } function openOSMSelectionDialog(listings) { function OSMSelectionDialog(config) { OSMSelectionDialog.super.call(this, config); } OO.inheritClass(OSMSelectionDialog, OO.ui.ProcessDialog); OSMSelectionDialog.static.name = 'osmSelectionDialog'; OSMSelectionDialog.static.title = `Select OSM listings (${listings.length} found)`; OSMSelectionDialog.static.actions = [ { action: 'insert', label: 'Insert to page', flags: ['primary', 'progressive'] }, { action: 'copy', label: 'Copy to clipboard or edit box' }, { label: 'Cancel', flags: 'safe' } ]; OSMSelectionDialog.prototype.initialize = function () { OSMSelectionDialog.super.prototype.initialize.call(this); this.panel = new OO.ui.PanelLayout({ padded: true, expanded: false }); this.checkboxGroups = []; this.categoryData = []; this.filterCheckbox = new OO.ui.CheckboxInputWidget({ selected: false }); const filterField = new OO.ui.FieldLayout(this.filterCheckbox, { label: 'Hide listings with name and coordinates only', align: 'inline' }); filterField.$element.css('margin-bottom', '1.5em'); this.panel.$element.append(filterField.$element); const categories = [ { id: 'see', label: 'See' }, { id: 'do', label: 'Do' }, { id: 'buy', label: 'Buy' }, { id: 'eat', label: 'Eat' }, { id: 'drink', label: 'Drink' }, { id: 'sleep', label: 'Sleep' } ]; categories.forEach(cat => { const catItems = listings.filter(l => l.type === cat.id); if (catItems.length === 0) return; const categoryItemWidgets = []; const options = catItems.map(l => { let fieldIndicator = l.fields.length > 0 ? ` (has: ${l.fields.join(', ')})` : ` (name & coords only)`; let dateSubtitle = l.lasteditFormatted ? `<br><span style="color: #72777d; font-size: 0.85em;">Last updated on OSM: ${l.lasteditFormatted}</span>` : ''; const optWidget = new OO.ui.CheckboxMultioptionWidget({ data: l, label: $('<span>').html(`${l.name}${fieldIndicator}${dateSubtitle}`) }); const hasActionable = !!(l.details.address || l.details.url || l.details.phone); categoryItemWidgets.push({ widget: optWidget, hasActionable: hasActionable }); return optWidget; }); const multiselect = new OO.ui.CheckboxMultiselectWidget({ items: options }); this.checkboxGroups.push(multiselect); const fieldset = new OO.ui.FieldsetLayout({ label: `${cat.label} (${catItems.length})`, $content: multiselect.$element }); this.panel.$element.append(fieldset.$element); fieldset.$element.css('margin-bottom', '1.5em'); this.categoryData.push({ fieldset: fieldset, items: categoryItemWidgets }); }); this.filterCheckbox.on('change', (checked) => { this.categoryData.forEach(cat => { let visibleCount = 0; cat.items.forEach(item => { const show = !checked || item.hasActionable; item.widget.$element.toggle(show); if (!show) { item.widget.setSelected(false); } else { visibleCount++; } }); cat.fieldset.$element.toggle(visibleCount > 0); }); }); this.$body.append(this.panel.$element); }; OSMSelectionDialog.prototype.getActionProcess = function (action) { const dialog = this; if (action === 'copy') { let selectedData = []; this.checkboxGroups.forEach(group => { selectedData = selectedData.concat(group.findSelectedItemsData()); }); if (selectedData.length > 0) { const finalWikitext = selectedData.map(item => item.wikitext).join('\n'); const $textbox =$('#wpTextbox1'); if ($textbox.length) {$textbox.textSelection('encapsulateSelection', { pre: finalWikitext }); mw.notify(`Inserted ${selectedData.length} listings into the edit box.`, { type: 'success' }); } else { navigator.clipboard.writeText(finalWikitext).then(() => { mw.notify(`Copied ${selectedData.length} listings to clipboard!`, { type: 'success' }); }); } } return new OO.ui.Process(function () { dialog.close({ action: action }); }, dialog); } if (action === 'insert') { let selectedData = []; this.checkboxGroups.forEach(group => { selectedData = selectedData.concat(group.findSelectedItemsData()); }); if (selectedData.length === 0) { mw.notify('No listings selected.', { type: 'warn' }); return new OO.ui.Process(); } return new OO.ui.Process(function () { dialog.pushPending(); return new mw.Api().get({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: mw.config.get('wgPageName'), formatversion: 2 }).then(data => { dialog.popPending(); let currentContent = ''; try { currentContent = data.query.pages[0].revisions[0].slots.main.content; } catch (e) {} const matchedIndices = new Set(); selectedData.forEach(item => { const existing = findExistingListingInWikitext(currentContent, item.name, item.lat, item.lon, matchedIndices); if (existing) { matchedIndices.add(existing.index); const mergeResult = mergeListingIntoWikitext(existing.fullTemplate, item); item.isDuplicate = true; item.existingMatch = existing; item.filledFields = mergeResult.filledFields; item.mergedTemplate = mergeResult.updatedTemplate; } else { item.isDuplicate = false; } }); dialog.close().closed.then(() => { openInsertReviewDialog(selectedData); }); }).catch((err) => { dialog.popPending(); dialog.showErrors(new OO.ui.Error(`Failed to read page content: ${err}`)); }); }, dialog); } return OSMSelectionDialog.super.prototype.getActionProcess.call(this, action); }; const windowManager = getOSMWindowManager(); const dialog = new OSMSelectionDialog({ size: 'larger' }); windowManager.addWindows([dialog]); windowManager.openWindow(dialog); } function openInsertReviewDialog(selectedListings) { function OSMReviewDialog(config) { OSMReviewDialog.super.call(this, config); } OO.inheritClass(OSMReviewDialog, OO.ui.ProcessDialog); OSMReviewDialog.static.name = 'osmReviewDialog'; OSMReviewDialog.static.title = 'Review listings to insert and update'; OSMReviewDialog.static.actions = [ { action: 'confirm', label: 'Confirm insertion', flags: ['primary', 'progressive'] }, { label: 'Cancel', flags: 'safe' } ]; OSMReviewDialog.prototype.initialize = function () { OSMReviewDialog.super.prototype.initialize.call(this); this.panel = new OO.ui.PanelLayout({ padded: true, expanded: false }); this.previewContainer = $('<div>'); const hasDuplicates = selectedListings.some(l => l.isDuplicate); if (hasDuplicates) { this.mergeCheckbox = new OO.ui.CheckboxInputWidget({ selected: true }); const mergeField = new OO.ui.FieldLayout(this.mergeCheckbox, { label: 'Merge missing details into existing listings where duplicates are found', align: 'inline' }); mergeField.$element.css('margin-bottom', '1.2em'); this.panel.$element.append(mergeField.$element); this.mergeCheckbox.on('change', () => { this.renderPreview(); }); } this.panel.$element.append(this.previewContainer); this.$body.append(this.panel.$element); this.renderPreview(); }; OSMReviewDialog.prototype.renderPreview = function () { const shouldMerge = this.mergeCheckbox ? this.mergeCheckbox.isSelected() : false; let previewHtml = ''; const categories = [ { id: 'see', label: 'See' }, { id: 'do', label: 'Do' }, { id: 'buy', label: 'Buy' }, { id: 'eat', label: 'Eat' }, { id: 'drink', label: 'Drink' }, { id: 'sleep', label: 'Sleep' } ]; if (shouldMerge) { const newItems = selectedListings.filter(l => !l.isDuplicate); const mergeItems = selectedListings.filter(l => l.isDuplicate && l.filledFields && l.filledFields.length > 0); const skipItems = selectedListings.filter(l => l.isDuplicate && (!l.filledFields || l.filledFields.length === 0)); if (mergeItems.length > 0) { previewHtml += `<h3 style="color:#2a4b8d; border-bottom: 1px solid #c8ccd1; padding-bottom: 0.2em;">Existing listings to update in-place (${mergeItems.length})</h3><ul style="margin-bottom: 1.5em;">`; mergeItems.forEach(item => { previewHtml += `<li style="margin-bottom:0.6em;"> <strong>${item.name}</strong> <span style="color:#72777d; font-size:0.85em;">(matches existing listing by ${item.existingMatch.matchType})</span><br> <span style="font-size:0.85em; color:#006699;"><strong>Will populate empty fields:</strong> ${item.filledFields.join(', ')}</span> </li>`; }); previewHtml += `</ul>`; } if (skipItems.length > 0) { previewHtml += `<h3 style="color:#72777d; border-bottom: 1px solid #c8ccd1; padding-bottom: 0.2em;">Existing listings already up to date (${skipItems.length})</h3><ul style="margin-bottom: 1.5em;">`; skipItems.forEach(item => { previewHtml += `<li style="margin-bottom:0.4em; color:#54595d;"> <strong>${item.name}</strong> <span style="font-size:0.85em;">(matches existing listing; all OSM details are already present)</span> </li>`; }); previewHtml += `</ul>`; } previewHtml += `<h3 style="border-bottom: 1px solid #c8ccd1; padding-bottom: 0.2em;">New listings to add (${newItems.length})</h3>`; if (newItems.length === 0) { previewHtml += `<p style="color:#72777d; font-style:italic;">No new listings to add.</p>`; } else { categories.forEach(cat => { const items = newItems.filter(l => l.type === cat.id); if (items.length > 0) { previewHtml += `<h4>${cat.label}</h4><ul style="margin-bottom: 1em;">`; items.forEach(item => { let parts = []; if (item.details.address) parts.push(`Address: ${item.details.address}`); if (item.details.hours) parts.push(`Hours: ${item.details.hours}`); if (item.details.price) parts.push(`Price: ${item.details.price}`); if (item.details.url) parts.push(`URL: ${item.details.url}`); if (item.details.phone) parts.push(`Phone: ${item.details.phone}`); if (item.details.email) parts.push(`Email: ${item.details.email}`); if (item.details.content) parts.push(`Description: ${item.details.content}`); if (item.details.lasteditFormatted) parts.push(`Last updated: ${item.details.lasteditFormatted}`); previewHtml += `<li style="margin-bottom:0.5em;"><strong>${item.name}</strong><br><span style="font-size:0.85em; color:#54595d;">${parts.join(' • ')}</span></li>`; }); previewHtml += `</ul>`; } }); } } else { previewHtml += `<p>The following listings will be added directly under their respective section headers:</p>`; categories.forEach(cat => { const items = selectedListings.filter(l => l.type === cat.id); if (items.length > 0) { previewHtml += `<h4>${cat.label}</h4><ul style="margin-bottom: 1em;">`; items.forEach(item => { let parts = []; if (item.details.address) parts.push(`Address: ${item.details.address}`); if (item.details.hours) parts.push(`Hours: ${item.details.hours}`); if (item.details.price) parts.push(`Price: ${item.details.price}`); if (item.details.url) parts.push(`URL: ${item.details.url}`); if (item.details.phone) parts.push(`Phone: ${item.details.phone}`); if (item.details.email) parts.push(`Email: ${item.details.email}`); if (item.details.content) parts.push(`Description: ${item.details.content}`); if (item.details.lasteditFormatted) parts.push(`Last updated: ${item.details.lasteditFormatted}`); let dupNotice = item.isDuplicate ? ` <span style="color:#d33; font-size:0.85em;">(duplicate detected)</span>` : ''; previewHtml += `<li style="margin-bottom:0.5em;"><strong>${item.name}</strong>${dupNotice}<br><span style="font-size:0.85em; color:#54595d;">${parts.join(' • ')}</span></li>`; }); previewHtml += `</ul>`; } }); } this.previewContainer.html(previewHtml); }; OSMReviewDialog.prototype.getActionProcess = function (action) { const dialog = this; if (action === 'confirm') { const shouldMerge = this.mergeCheckbox ? this.mergeCheckbox.isSelected() : false; return new OO.ui.Process(function () { dialog.pushPending(); const pageName = mw.config.get('wgPageName'); return new mw.Api().edit(pageName, function (revision) { let newContent = revision.content || ''; let mergedCount = 0; let addedCount = 0; if (shouldMerge) { selectedListings.forEach(item => { if (item.isDuplicate && item.filledFields && item.filledFields.length > 0) { if (newContent.includes(item.existingMatch.fullTemplate)) { newContent = newContent.replace(item.existingMatch.fullTemplate, item.mergedTemplate); mergedCount++; } } }); } const itemsToInsert = shouldMerge ? selectedListings.filter(l => !l.isDuplicate) : selectedListings; const categorizedData = { see: [], do: [], buy: [], eat: [], drink: [], sleep: [] }; itemsToInsert.forEach(item => { categorizedData[item.type].push(item.wikitext); addedCount++; }); for (const [type, items] of Object.entries(categorizedData)) { if (items.length === 0) continue; const singleCategoryName = type.charAt(0).toUpperCase() + type.slice(1); const listingsText = items.join('\n'); let candidateHeaders = []; if (type === 'see' || type === 'do') { candidateHeaders = ['See and do', 'Do and see', singleCategoryName]; } else if (type === 'eat' || type === 'drink') { candidateHeaders = ['Eat and drink', 'Drink and eat', singleCategoryName]; } else { candidateHeaders = [singleCategoryName]; } let matchedHeader = null; let headerLevel = 2; for (const h of candidateHeaders) { const testRegex = new RegExp(`(={2,5})\\s*${h}\\s*\\1`, 'i'); const m = newContent.match(testRegex); if (m) { matchedHeader = h; headerLevel = m[1].length; break; } } if (matchedHeader) { const sectionRegex = new RegExp( `(={${headerLevel}}\\s*${matchedHeader}\\s*={${headerLevel}}[\\s\\S]*?)(?=\\r?\\n={1,${headerLevel}}(?!\\=)|$)`, 'i' ); if (sectionRegex.test(newContent)) { newContent = newContent.replace(sectionRegex, (match, p1) => { return p1.trimEnd() + '\n' + listingsText + '\n'; }); } else { newContent = newContent.trimEnd() + `\n\n== ${singleCategoryName} ==\n${listingsText}\n`; } } else { newContent = newContent.trimEnd() + `\n\n== ${singleCategoryName} ==\n${listingsText}\n`; } } let summaryParts = []; if (addedCount > 0) summaryParts.push(`add ${addedCount} ${addedCount === 1 ? 'listing' : 'listings'}`); if (mergedCount > 0) summaryParts.push(`update ${mergedCount} existing ${mergedCount === 1 ? 'listing' : 'listings'}`); const summaryAction = summaryParts.length ? summaryParts.join(' and ') : 'import listings'; return { text: newContent, summary: `${summaryAction.charAt(0).toUpperCase() + summaryAction.slice(1)} from © OpenStreetMap contributors under the Open Database License (ODbL) via [[User:SHB/OSMFetch|OSMFetch]]` }; }).then(() => { dialog.close(); mw.notify('Listings processed and saved directly to the page!', { type: 'success' }); setTimeout(() => { location.reload(); }, 1500); }).catch((code, err) => { dialog.popPending(); dialog.showErrors(new OO.ui.Error(`Failed to save: ${err.info || code}`)); }); }, dialog); } return OSMReviewDialog.super.prototype.getActionProcess.call(this, action); }; const windowManager = getOSMWindowManager(); const dialog = new OSMReviewDialog({ size: 'larger' }); windowManager.addWindows([dialog]); windowManager.openWindow(dialog); } // </nowiki>