User:SHB/OSMFetch.js

// <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(' &bull; ')}</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(' &bull; ')}</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>