// ==UserScript==
// @name WME Quick HN Importer - Slovenia
// @namespace https://github.com/zigapovhe/wme-sl-hn-import
// @version 2.2.0
// @description Quickly add Slovenian house numbers with clickable overlays
// @author ThatByte
// @downloadURL https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/wme-sl-hn-import.user.js
// @updateURL https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/wme-sl-hn-import.user.js
// @supportURL https://github.com/zigapovhe/wme-sl-hn-import/issues
// @icon https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/icon48.png
// @icon64 https://raw.githubusercontent.com/zigapovhe/wme-sl-hn-import/main/icon64.png
// @match https://www.waze.com/editor*
// @match https://www.waze.com/*/editor*
// @match https://beta.waze.com/*
// @exclude https://www.waze.com/user/editor*
// @connect ipi.eprostor.gov.si
// @connect raw.githubusercontent.com
// @require https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.9.0/proj4.js
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @license MIT
// @noframes
// ==/UserScript==
/*
* Click handling and nearest segment matching based on work by
* Tom 'Glodenox' Puttemans (https://github.com/Glodenox/wme-quick-hn-importer)
*/
/* global I18n, proj4, getWmeSdk, unsafeWindow */
(function () {
'use strict';
let wmeSDK;
const SDK_LAYER_NAME = 'qhnsl-sdk';
const SDK_NAVPOINTS_LAYER_NAME = 'qhnsl-navpoints';
const MAX_CLICK_DISTANCE_PX = 25;
const MAX_HN_CONFLICT_DISTANCE = 10;
// EProstor API configuration
const EPROSTOR_API = 'https://ipi.eprostor.gov.si/wfs-si-gurs-rn/ogc/features/collections/SI.GURS.RN:REGISTER_NASLOVOV/items';
const EPROSTOR_LIMIT = 1000;
// Common Slovenian street name abbreviations
const ABBREVIATIONS = {
'c.': 'cesta',
'ul.': 'ulica',
'nab.': 'nabrežje',
'trg.': 'trg'
};
const LS = {
getBuffer() { return Number(localStorage.getItem('qhnsl-buffer') ?? '500'); },
setBuffer(v) { localStorage.setItem('qhnsl-buffer', String(v)); },
getLayerVisible() { return localStorage.getItem('qhnsl-layer-visible') === '1'; },
setLayerVisible(v){ localStorage.setItem('qhnsl-layer-visible', v ? '1' : '0'); },
getSelectedOnly() { return localStorage.getItem('qhnsl-selected-only') === '1'; },
setSelectedOnly(v){ localStorage.setItem('qhnsl-selected-only', v ? '1' : '0'); },
getNavPoints() { return localStorage.getItem('qhnsl-navpoints') === '1'; },
setNavPoints(v) { localStorage.setItem('qhnsl-navpoints', v ? '1' : '0'); }
};
const toast = (msg, type = 'info') => {
try {
if (wmeSDK?.Notifications?.show) {
wmeSDK.Notifications.show({ text: msg, type, timeout: 3500 });
} else {
console.info(`[SL-HN] ${msg}`);
}
} catch (_) {
console.info(`[SL-HN] ${msg}`);
}
};
// EPSG:3794 definition (Slovenia D96/TM)
if (!proj4.defs['EPSG:3794']) {
proj4.defs(
'EPSG:3794',
'+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs'
);
}
function normalizeStreetName(name) {
return String(name).toLowerCase().replace(/\s+/g, '_');
}
// Escape HTML special characters for safe attribute insertion
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// Expand abbreviations and normalize for comparison
function normalizeForComparison(name) {
let normalized = String(name).toLowerCase().trim();
for (const [abbrev, full] of Object.entries(ABBREVIATIONS)) {
const escapedAbbrev = abbrev.replace(/\./g, '\\.');
const regex = new RegExp('(^|\\s)' + escapedAbbrev + '(?=\\s|$)', 'gi');
normalized = normalized.replace(regex, '$1' + full);
}
// Remove extra whitespace
normalized = normalized.replace(/\s+/g, ' ');
return normalized;
}
function removeDiacritics(str) {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
// Calculate similarity between two strings (0-1)
function calculateSimilarity(str1, str2) {
const s1 = normalizeForComparison(str1);
const s2 = normalizeForComparison(str2);
// Exact match after normalization
if (s1 === s2) return 1.0;
// Match without diacritics
if (removeDiacritics(s1) === removeDiacritics(s2)) return 0.95;
// Levenshtein distance based similarity
const distance = levenshteinDistance(s1, s2);
const maxLen = Math.max(s1.length, s2.length);
const similarity = 1 - (distance / maxLen);
return similarity;
}
// Levenshtein distance implementation
function levenshteinDistance(str1, str2) {
const m = str1.length;
const n = str2.length;
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
function getHNGeometry(hn) {
if (!hn?.geometry?.coordinates) return null;
return { x: hn.geometry.coordinates[0], y: hn.geometry.coordinates[1] };
}
function getSelectedSegments() {
const sel = wmeSDK.Editing.getSelection();
if (!sel || sel.objectType !== 'segment') return [];
return sel.ids
.map(id => wmeSDK.DataModel.Segments.getById({ segmentId: id }))
.filter(Boolean);
}
// Build house number string from components
function buildHouseNumber(stevilka, dodatek) {
let hn = String(stevilka || '').trim();
if (dodatek) {
hn += String(dodatek).trim();
}
return hn.toLowerCase();
}
// Check if a house number has a nearby conflict (different HN within threshold distance)
function hasConflict(hn, wx, wy, entry) {
if (!entry?.items?.length) return false;
for (const it of entry.items) {
if (!it || it.x == null || it.y == null) continue;
if (it.num !== hn) {
const dx = wx - it.x, dy = wy - it.y;
if (dx * dx + dy * dy <= MAX_HN_CONFLICT_DISTANCE * MAX_HN_CONFLICT_DISTANCE) {
return true;
}
}
}
return false;
}
// Build CQL filter for coordinate bounds (excludes apartments)
function buildCqlFilter(minE, minN, maxE, maxN) {
return `E>=${minE} AND E<=${maxE} AND N>=${minN} AND N<=${maxN} AND ST_STANOVANJA IS NULL`;
}
// Fetch addresses from EProstor API with pagination
function fetchAddresses(minE, minN, maxE, maxN) {
return new Promise((resolve, reject) => {
const allFeatures = [];
let startIndex = 0;
function fetchPage() {
const filter = buildCqlFilter(minE, minN, maxE, maxN);
const url = EPROSTOR_API +
'?f=application/json' +
'&limit=' + EPROSTOR_LIMIT +
'&startIndex=' + startIndex +
'&filter=' + encodeURIComponent(filter) +
'&filter-lang=cql-text';
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 30000,
onload: function (response) {
try {
const data = JSON.parse(response.responseText);
if (!data.features || !Array.isArray(data.features)) {
if (allFeatures.length > 0) {
resolve(allFeatures);
} else {
reject(new Error('Invalid API response'));
}
return;
}
allFeatures.push(...data.features);
// Check if there are more pages
const returned = data.numberReturned || data.features.length;
if (returned >= EPROSTOR_LIMIT) {
startIndex += EPROSTOR_LIMIT;
fetchPage();
} else {
resolve(allFeatures);
}
} catch (err) {
reject(err);
}
},
onerror: function (err) {
reject(err);
},
ontimeout: function () {
reject(new Error('EProstor request timed out after 30s'));
}
});
}
fetchPage();
});
}
// Copy text to clipboard
function copyToClipboard(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
toast(`Copied "${text}" to clipboard`, 'success');
} else {
navigator.clipboard.writeText(text).then(() => {
toast(`Copied "${text}" to clipboard`, 'success');
}).catch(() => {
toast('Failed to copy to clipboard', 'error');
});
}
}
// Update selected segment's street name via WME SDK
function updateSegmentStreetName(newStreetName, onSuccess) {
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) {
toast('No segment selected', 'warning');
return;
}
const segment = selectedSegments[0];
const segmentId = segment.id;
// Get current city from the segment
const currentStreetId = segment.primaryStreetId;
const currentStreet = currentStreetId ? wmeSDK.DataModel.Streets.getById({ streetId: currentStreetId }) : null;
const cityId = currentStreet?.cityId;
if (!cityId) {
toast('Segment has no city assigned', 'warning');
return;
}
try {
// First, try to get existing street with this name in this city
let street = wmeSDK.DataModel.Streets.getStreet({
cityId: cityId,
streetName: newStreetName
});
// If not found, create the street
if (!street) {
console.debug('[SL-HN] Street not found, creating new street:', newStreetName);
street = wmeSDK.DataModel.Streets.addStreet({
streetName: newStreetName,
cityId: cityId
});
}
console.debug('[SL-HN] Got street:', street);
// Now update the segment with the new street ID
wmeSDK.DataModel.Segments.updateAddress({
segmentId: segmentId,
primaryStreetId: street.id
});
console.debug('[SL-HN] Updated segment', segmentId, 'to street ID:', street.id);
toast(`Updated street to "${newStreetName}"`, 'success');
if (typeof onSuccess === 'function') {
onSuccess();
}
} catch (err) {
console.error('[SL-HN] Error updating street name:', err);
toast('Error updating street name. See console.', 'error');
}
}
function init() {
let currentStreetId = null;
let streetNames = {};
let streets = {};
let lastFeatures = [];
let lastSdkFeatureIds = [];
let isLoading = false;
let currentLoadId = 0;
let userWantsLayerVisible = false;
let streetNameSpan = null;
let currentStreetDiv = null;
let streetAnalysisDiv = null;
let chkMissing = null;
let chkSelectedOnly = null;
let applyFeatureFilter = () => {};
let analyzeStreetMatches = () => {};
try {
I18n.translations[I18n.currentLocale()].layers.name['quick-hn-sl-importer'] = 'Quick HN Importer';
} catch (_) {}
wmeSDK.Map.addLayer({
layerName: SDK_LAYER_NAME,
zIndexing: true,
styleContext: {
getFillColor: ({ feature }) => {
const p = feature.properties;
if (p.conflict) return '#ff6666';
return p.isSelectedStreet ? '#99ee99' : '#fb9c4f';
},
getOpacity: ({ feature }) => {
const p = feature.properties;
if (p.conflict) return 1;
return (p.isSelectedStreet && p.processed) ? 0.3 : 1;
},
getRadius: ({ feature }) => {
const num = feature.properties.number;
return num ? Math.max(String(num).length * 7, 12) : 12;
},
getLabel: ({ feature }) => String(feature.properties.number ?? '')
},
styleRules: [{
style: {
graphicName: 'circle',
pointRadius: '${getRadius}',
fillColor: '${getFillColor}',
fillOpacity: '${getOpacity}',
strokeColor: '#ffffff',
strokeWidth: 2,
strokeOpacity: '${getOpacity}',
label: '${getLabel}',
fontColor: '#111111',
fontWeight: 'bold',
labelOutlineColor: '#ffffff',
labelOutlineWidth: 0
}
}]
});
wmeSDK.Map.setLayerVisibility({ layerName: SDK_LAYER_NAME, visibility: false });
let lastComputedVisibility = false;
function updateLayerVisibility() {
const currentZoom = wmeSDK.Map.getZoomLevel();
const shouldBeVisible = userWantsLayerVisible && currentZoom >= 18;
if (shouldBeVisible === lastComputedVisibility) return;
lastComputedVisibility = shouldBeVisible;
wmeSDK.Map.setLayerVisibility({ layerName: SDK_LAYER_NAME, visibility: shouldBeVisible });
if (userWantsLayerVisible && !shouldBeVisible && lastFeatures.length > 0) {
toast('Zoom in to level 18+ to see house numbers', 'info');
}
}
wmeSDK.Events.on({ eventName: 'wme-map-zoom-changed', eventHandler: updateLayerVisibility });
wmeSDK.Events.on({ eventName: 'wme-map-move-end', eventHandler: updateLayerVisibility });
wmeSDK.Events.on({ eventName: 'wme-selection-changed', eventHandler: onSelectionChanged });
// Get current WME street name from selection
function getWmeStreetName() {
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) return null;
const seg = selectedSegments[0];
const primaryStreetId = seg.primaryStreetId;
if (!primaryStreetId) return null;
const street = wmeSDK.DataModel.Streets.getById({ streetId: primaryStreetId });
return street?.name || null;
}
// Analyze street name matches and update UI
analyzeStreetMatches = function() {
if (!streetAnalysisDiv) return;
if (!lastFeatures.length) {
streetAnalysisDiv.style.display = 'none';
return;
}
const wmeStreetName = getWmeStreetName();
// Count addresses per official street name
const streetCounts = {};
lastFeatures.forEach(f => {
const name = streetNames[f.street];
if (!name) return;
streetCounts[name] = (streetCounts[name] || 0) + 1;
});
// Sort by count descending
// Always order by number of loaded HNs (desc), then alphabetically for stability
const sorted = Object.entries(streetCounts)
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]));
if (sorted.length === 0) {
streetAnalysisDiv.style.display = 'none';
return;
}
// Check how many match current WME street
const matchCount = wmeStreetName ? (streetCounts[wmeStreetName] || 0) : 0;
const hasMismatch = wmeStreetName && matchCount === 0 && sorted.length > 0;
// Find fuzzy match if there's a mismatch
let suggestedMatch = null;
let suggestionSimilarity = 0;
if (hasMismatch && wmeStreetName) {
for (const [name] of sorted) {
const similarity = calculateSimilarity(wmeStreetName, name);
if (similarity > 0.7 && similarity > suggestionSimilarity) {
suggestedMatch = name;
suggestionSimilarity = similarity;
}
}
}
// Build HTML
let html = '';
if (hasMismatch) {
html += `<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:4px;padding:8px;margin-bottom:8px;">`;
html += `<b style="color:#856404;">⚠️ No matching addresses found!</b><br/>`;
html += `<span style="font-size:11px;color:#856404;">WME street name doesn't match any official names</span>`;
html += `</div>`;
if (suggestedMatch) {
const escapedSuggested = escapeHtml(suggestedMatch);
html += `<div style="background:#d4edda;border:1px solid #28a745;border-radius:4px;padding:8px;margin-bottom:8px;">`;
html += `<b style="color:#155724;">💡 Possible match found:</b><br/>`;
html += `<div style="margin:4px 0;font-size:12px;">`;
html += `<span style="color:#666;">WME:</span> <span style="color:#dc3545;text-decoration:line-through;">${escapeHtml(wmeStreetName)}</span><br/>`;
html += `<span style="color:#666;">Official:</span> <b style="color:#155724;">${escapedSuggested}</b>`;
html += `</div>`;
html += `<div style="display:flex;gap:6px;margin-top:6px;">`;
html += `<button class="wz-button update-street-btn" data-street="${escapedSuggested}" style="font-size:11px;padding:2px 8px;">✓ Use official name</button>`;
html += `<button class="copy-street-btn" data-street="${escapedSuggested}" style="font-size:11px;padding:2px 8px;background:#f8f8f8;border:1px solid #ccc;border-radius:3px;cursor:pointer;">📋 Copy</button>`;
html += `</div>`;
html += `</div>`;
}
}
html += `<div style="font-size:12px;margin-bottom:4px;"><b>Official streets in area:</b></div>`;
html += `<div style="max-height:150px;overflow-y:auto;border:1px solid #ddd;border-radius:4px;background:#fafafa;">`;
sorted.forEach(([name, _count], index) => {
const isMatch = name === wmeStreetName;
const isSuggestion = name === suggestedMatch;
const escapedName = escapeHtml(name);
let rowStyle = 'padding:4px 8px;font-size:11px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;';
if (isMatch) rowStyle += 'background:#d4edda;';
else if (isSuggestion) rowStyle += 'background:#fff3cd;';
else if (index % 2 === 0) rowStyle += 'background:#f8f8f8;';
html += `<div style="${rowStyle}">`;
html += `<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapedName}">`;
if (isMatch) html += '✓ ';
if (isSuggestion) html += '→ ';
html += `${escapedName}</span>`;
html += `<span style="margin-left:8px;white-space:nowrap;display:flex;align-items:center;gap:4px;">`;
// Always show the update button - if already matched, show as disabled-looking but still clickable
const btnStyle = isMatch
? 'padding:1px 4px;font-size:10px;cursor:default;border:1px solid #ccc;border-radius:2px;background:#e9e9e9;color:#999;'
: 'padding:1px 4px;font-size:10px;cursor:pointer;border:1px solid #28a745;border-radius:2px;background:#d4edda;color:#155724;';
html += `<button class="update-street-btn" data-street="${escapedName}" style="${btnStyle}" title="${isMatch ? 'Already set' : 'Use this name'}">${isMatch ? '✓' : '→'}</button>`;
html += `<button class="copy-street-btn" data-street="${escapedName}" style="padding:1px 4px;font-size:10px;cursor:pointer;border:1px solid #ccc;border-radius:2px;background:#fff;" title="Copy to clipboard">📋</button>`;
html += `</span>`;
html += `</div>`;
});
html += `</div>`;
html += `<div style="font-size:10px;color:#888;margin-top:4px;">→ = apply name • 📋 = copy</div>`;
streetAnalysisDiv.innerHTML = html;
streetAnalysisDiv.style.display = 'block';
// Add click handlers for copy buttons
streetAnalysisDiv.querySelectorAll('.copy-street-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const streetName = btn.getAttribute('data-street');
copyToClipboard(streetName);
});
});
// Add click handlers for update buttons
streetAnalysisDiv.querySelectorAll('.update-street-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const streetName = btn.getAttribute('data-street');
// Check if this street is already set on the current segment
const currentWmeStreet = getWmeStreetName();
if (currentWmeStreet === streetName) {
toast('Street name already set', 'info');
return;
}
updateSegmentStreetName(streetName, () => {
// After successful update, refresh the current street state
// Find the street ID for the new street name
const newStreetId = streets[streetName];
if (newStreetId) {
currentStreetId = newStreetId;
// Update the "Current street" display
if (streetNameSpan && currentStreetDiv) {
streetNameSpan.textContent = streetName;
currentStreetDiv.style.display = 'block';
}
}
// Re-analyze and redraw with updated state
setTimeout(() => {
analyzeStreetMatches();
applyFeatureFilter();
}, 100);
});
});
});
};
function onSelectionChanged() {
if (!lastFeatures.length) return;
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) {
return;
}
const selectedStreetIds = new Set();
selectedSegments.forEach(seg => {
const psid = seg.primaryStreetId;
if (psid && psid > 0) selectedStreetIds.add(psid);
(seg.alternateStreetIds || []).forEach(id => {
if (id && id > 0) selectedStreetIds.add(id);
});
});
if (selectedStreetIds.size === 0) {
currentStreetId = null;
if (streetNameSpan && currentStreetDiv) {
streetNameSpan.textContent = '—';
currentStreetDiv.style.display = 'none';
}
applyFeatureFilter();
analyzeStreetMatches();
return;
}
const selectedStreetNames = Array.from(selectedStreetIds)
.map(id => wmeSDK.DataModel.Streets.getById({ streetId: id })?.name)
.filter(Boolean);
let newStreetId = null;
let bestCount = -1;
selectedStreetNames.forEach(name => {
const sid = streets[name];
if (!sid) return;
const count = lastFeatures.reduce(
(n, f) => n + (f.street === sid ? 1 : 0),
0
);
if (count > bestCount) {
bestCount = count;
newStreetId = sid;
}
});
if (!newStreetId) {
currentStreetId = null;
if (streetNameSpan && currentStreetDiv) {
streetNameSpan.textContent = '—';
currentStreetDiv.style.display = 'none';
}
applyFeatureFilter();
analyzeStreetMatches();
return;
}
// Always update state and refresh UI, even if street is the same
// (because we might be on a different segment with the same street)
currentStreetId = newStreetId;
if (streetNameSpan && currentStreetDiv && streetNames[currentStreetId]) {
streetNameSpan.textContent = streetNames[currentStreetId];
currentStreetDiv.style.display = 'block';
}
applyFeatureFilter();
analyzeStreetMatches();
}
function handleMapClick(evt) {
if (!userWantsLayerVisible || !lastFeatures.length) return;
if (evt == null || evt.x == null || evt.y == null) return;
const MAX_PIXELS_SQ = MAX_CLICK_DISTANCE_PX * MAX_CLICK_DISTANCE_PX;
let bestFeature = null;
let bestDistSq = Infinity;
for (const f of lastFeatures) {
if (f.lon == null || f.lat == null) continue;
const fPx = wmeSDK.Map.getMapPixelFromLonLat({ lonLat: { lon: f.lon, lat: f.lat } });
if (!fPx) continue;
const dx = fPx.x - evt.x;
const dy = fPx.y - evt.y;
const d2 = dx * dx + dy * dy;
if (d2 <= MAX_PIXELS_SQ && d2 < bestDistSq) {
bestDistSq = d2;
bestFeature = f;
}
}
if (!bestFeature) return;
onFeatureClick(bestFeature);
}
wmeSDK.Events.on({ eventName: 'wme-map-mouse-click', eventHandler: handleMapClick });
function onFeatureClick(feature) {
if (feature.processed) return;
const streetName = streetNames[feature.street];
const houseNumber = feature.number;
let nearestSegment = findNearestSegment(feature, streetName, true);
if (!nearestSegment) {
nearestSegment = findNearestSegment(feature, streetName, false);
if (!nearestSegment) {
toast('No nearby segment found', 'warning');
return;
}
const nearestStreet = wmeSDK.DataModel.Streets.getById({ streetId: nearestSegment.primaryStreetId });
const nearestStreetName = nearestStreet?.name || 'Unknown';
if (!confirm(`Street name "${streetName}" could not be found.\n\nDo you want to add this number to "${nearestStreetName}"?`)) {
return;
}
}
wmeSDK.Editing.setSelection({ selection: { ids: [nearestSegment.id], objectType: 'segment' } });
try {
wmeSDK.DataModel.HouseNumbers.addHouseNumber({
number: houseNumber,
point: { type: 'Point', coordinates: [feature.lon, feature.lat] },
segmentId: nearestSegment.id
});
feature.userAdded = true;
feature.processed = true;
feature.conflict = false;
applyFeatureFilter();
console.log('[SL-HN] Added house number', houseNumber);
toast(`Added house number ${houseNumber}`, 'success');
} catch (err) {
console.error('[SL-HN] Error adding house number:', err);
toast('Error adding house number. See console.', 'error');
}
}
function findNearestSegment(feature, streetName, matchName) {
const point = { x: feature.lon, y: feature.lat };
const allSegments = wmeSDK.DataModel.Segments.getAll();
let candidateSegments = allSegments;
if (matchName) {
const matchingStreetIds = wmeSDK.DataModel.Streets.getAll()
.filter(street => street.name?.toLowerCase() === streetName.toLowerCase())
.map(street => street.id);
if (matchingStreetIds.length === 0) {
return null;
}
candidateSegments = allSegments.filter(segment => {
const primaryMatch = matchingStreetIds.includes(segment.primaryStreetId);
const altMatch = (segment.alternateStreetIds || []).some(id => matchingStreetIds.includes(id));
return primaryMatch || altMatch;
});
}
if (candidateSegments.length === 0) {
return null;
}
let nearestSegment = null;
let minDistance = Infinity;
candidateSegments.forEach(segment => {
const coords = segment.geometry?.coordinates;
if (!Array.isArray(coords) || coords.length < 2) return;
const distance = pointToLineDistance(point, coords);
if (distance < minDistance) {
minDistance = distance;
nearestSegment = segment;
}
});
return nearestSegment;
}
function pointToLineDistance(point, coords) {
const px = point.x;
const py = point.y;
let minDist = Infinity;
for (let i = 0; i < coords.length - 1; i++) {
const [x1, y1] = coords[i];
const [x2, y2] = coords[i + 1];
const dist = pointToSegmentDistance(px, py, x1, y1, x2, y2);
if (dist < minDist) minDist = dist;
}
return minDist;
}
function pointToSegmentDistance(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
const dpx = px - x1;
const dpy = py - y1;
return Math.sqrt(dpx * dpx + dpy * dpy);
}
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSquared;
t = Math.max(0, Math.min(1, t));
const closestX = x1 + t * dx;
const closestY = y1 + t * dy;
const dpx = px - closestX;
const dpy = py - closestY;
return Math.sqrt(dpx * dpx + dpy * dpy);
}
const loading = document.createElement('div');
loading.style.position = 'absolute';
loading.style.bottom = '35px';
loading.style.width = '100%';
loading.style.pointerEvents = 'none';
loading.style.display = 'none';
loading.innerHTML =
'<div style="margin:0 auto; max-width:300px; text-align:center; background:rgba(0, 0, 0, 0.5); color:white; border-radius:3px; padding:5px 15px;"><i class="fa fa-pulse fa-spinner"></i> Loading address points</div>';
document.getElementById('map').appendChild(loading);
wmeSDK.Sidebar.registerScriptTab().then(({ tabLabel, tabPane }) => {
tabLabel.innerText = 'SL-HN';
tabLabel.title = 'Quick HN Importer (Slovenia)';
tabPane.innerHTML = `
<div id="qhnsl-pane" style="padding:10px;">
<h2 style="margin-top:0;">Quick HN Importer 🇸🇮</h2>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin:4px 0 8px 0;">
<button id="hn-load" class="wz-button"><span id="hn-load-label">Load selected street</span> <kbd style="margin-left:6px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:rgba(0,0,0,0.08);border-radius:3px;padding:2px 5px;color:#555;">Alt+Shift+L</kbd></button>
<button id="hn-clear" class="wz-button wz-button--secondary">Clear <kbd style="margin-left:6px;font-size:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:rgba(0,0,0,0.08);border-radius:3px;padding:2px 5px;color:#555;">Alt+Shift+K</kbd></button>
</div>
<div id="hn-current-street" style="margin:8px 0;padding:8px;background:#f0f0f0;border-radius:4px;font-size:13px;display:none;">
<b>WME selected street:</b> <span id="hn-street-name" style="color:#2a7;font-weight:bold;">—</span>
</div>
<div id="hn-street-analysis" style="margin:8px 0;display:none;"></div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
<wz-checkbox id="hn-toggle">Show layer</wz-checkbox>
<wz-checkbox id="qhnsl-missing">Show only missing</wz-checkbox>
<wz-checkbox id="qhnsl-selected-only">Selected street only</wz-checkbox>
<wz-checkbox id="qhnsl-navpoints">Show HN NavPoints</wz-checkbox>
<span style="font-size:12px;">Buffer (m): <input id="qhnsl-buffer" type="number" min="0" step="50" style="width:80px;margin-left:6px"></span>
</div>
<div id="hn-status" style="margin-top:10px;font-size:12px;color:#666;line-height:1.4;">
<b>Instructions</b><br/>
1) Select a segment • 2) Click "Load selected street" • 3) <b>Click house numbers on map to add them</b><br/>
Green = selected street • Orange = other streets • Red = possible wrong HN • Faded = already in WME
</div>
</div>
`;
const btnLoad = tabPane.querySelector('#hn-load');
const btnLoadLabel = tabPane.querySelector('#hn-load-label');
const btnClear = tabPane.querySelector('#hn-clear');
const chkVis = tabPane.querySelector('#hn-toggle');
chkMissing = tabPane.querySelector('#qhnsl-missing');
chkSelectedOnly = tabPane.querySelector('#qhnsl-selected-only');
const chkNavPoints = tabPane.querySelector('#qhnsl-navpoints');
const bufferEl = tabPane.querySelector('#qhnsl-buffer');
const statusDiv = tabPane.querySelector('#hn-status');
currentStreetDiv = tabPane.querySelector('#hn-current-street');
streetNameSpan = tabPane.querySelector('#hn-street-name');
streetAnalysisDiv = tabPane.querySelector('#hn-street-analysis');
const isChecked = (el) => el?.hasAttribute('checked');
const setChecked = (el, v) => v ? el.setAttribute('checked', '') : el.removeAttribute('checked');
bufferEl.value = String(LS.getBuffer());
if (LS.getLayerVisible()) {
setChecked(chkVis, true);
userWantsLayerVisible = true;
updateLayerVisibility();
}
if (LS.getSelectedOnly()) {
setChecked(chkSelectedOnly, true);
}
setChecked(chkNavPoints, LS.getNavPoints());
bufferEl.addEventListener('change', () => {
const val = Number(bufferEl.value);
if (!Number.isFinite(val) || val < 0) {
bufferEl.value = String(LS.getBuffer());
return;
}
LS.setBuffer(val);
});
chkVis.addEventListener('click', () => {
const on = isChecked(chkVis);
setChecked(chkVis, !on);
userWantsLayerVisible = !on;
LS.setLayerVisible(!on);
updateLayerVisibility();
});
chkMissing.addEventListener('click', () => {
setChecked(chkMissing, !isChecked(chkMissing));
applyFeatureFilter();
});
chkSelectedOnly.addEventListener('click', () => {
const newState = !isChecked(chkSelectedOnly);
setChecked(chkSelectedOnly, newState);
LS.setSelectedOnly(newState);
applyFeatureFilter();
});
async function loadSelectedStreet() {
if (isLoading) return;
isLoading = true;
const myLoadId = ++currentLoadId;
btnLoad.disabled = true;
btnLoadLabel.textContent = 'Loading…';
if (lastSdkFeatureIds.length) {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_LAYER_NAME, featureIds: lastSdkFeatureIds });
lastSdkFeatureIds = [];
}
streets = {};
streetNames = {};
currentStreetId = null;
lastFeatures = [];
streetAnalysisDiv.style.display = 'none';
await updateLayer(statusDiv, myLoadId).catch(err => console.warn('SL-HN updateLayer:', err));
// Skip post-load side effects if user clicked Clear (or another Load) mid-fetch
if (myLoadId === currentLoadId) {
userWantsLayerVisible = true;
setChecked(chkVis, true);
LS.setLayerVisible(true);
updateLayerVisibility();
}
btnLoad.disabled = false;
btnLoadLabel.textContent = 'Load selected street';
isLoading = false;
}
btnLoad.addEventListener('click', loadSelectedStreet);
function clearLayer() {
currentLoadId++; // invalidate any in-flight load so its results are discarded
if (lastSdkFeatureIds.length) {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_LAYER_NAME, featureIds: lastSdkFeatureIds });
lastSdkFeatureIds = [];
}
userWantsLayerVisible = false;
wmeSDK.Map.setLayerVisibility({ layerName: SDK_LAYER_NAME, visibility: false });
setChecked(chkVis, false);
LS.setLayerVisible(false);
streets = {};
streetNames = {};
currentStreetId = null;
lastFeatures = [];
currentStreetDiv.style.display = 'none';
streetAnalysisDiv.style.display = 'none';
statusDiv.innerHTML = `<b>Instructions</b><br/>
1) Select a segment • 2) Click "Load selected street" • 3) <b>Click house numbers on map to add them</b><br/>
Green = selected street • Orange = other streets • Red = possible wrong HN • Faded = already in WME`;
}
btnClear.addEventListener('click', clearLayer);
applyFeatureFilter = function () {
const onlyMissing = chkMissing?.hasAttribute('checked');
const selectedOnly = chkSelectedOnly?.hasAttribute('checked');
const visible = lastFeatures.filter(feat => {
if (onlyMissing && feat.processed) return false;
if (selectedOnly && currentStreetId && feat.street !== currentStreetId) return false;
return true;
});
if (lastSdkFeatureIds.length) {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_LAYER_NAME, featureIds: lastSdkFeatureIds });
}
const visibleSdk = visible.map((feat, i) => ({
type: 'Feature',
id: `qhnsl-${i}`,
geometry: { type: 'Point', coordinates: [feat.lon, feat.lat] },
properties: {
number: feat.number,
street: feat.street,
processed: feat.processed,
conflict: feat.conflict,
isSelectedStreet: feat.street === currentStreetId
}
}));
wmeSDK.Map.addFeaturesToLayer({ layerName: SDK_LAYER_NAME, features: visibleSdk });
lastSdkFeatureIds = visibleSdk.map(f => f.id);
};
async function recalculateFeatureStates() {
if (!lastFeatures.length) return;
const selectionHNMap = await getVisibleHNsByStreet();
lastFeatures.forEach(feat => {
const { number: hn, street: streetId, eX, eY } = feat;
if (!hn || !streetId) return;
const entry = selectionHNMap.get(streetId);
const processed = (entry?.set.has(hn) === true) || feat.userAdded === true;
const conflict = !processed && hasConflict(hn, eX, eY, entry);
feat.processed = processed;
feat.conflict = conflict;
});
applyFeatureFilter();
}
function setupHouseNumberEventListeners() {
const events = [
'wme-house-number-added',
'wme-house-number-deleted',
'wme-house-number-moved',
'wme-house-number-updated'
];
events.forEach(eventName => {
wmeSDK.Events.on({
eventName,
eventHandler: () => {
if (lastFeatures.length > 0) {
recalculateFeatureStates().catch(err => console.warn('[SL-HN] recalculate failed:', err));
}
}
});
});
wmeSDK.Events.on({
eventName: 'wme-map-data-loaded',
eventHandler: () => {
if (lastFeatures.length > 0) {
recalculateFeatureStates().catch(err => console.warn('[SL-HN] recalculate failed:', err));
}
}
});
// Listen for segment edits (like street name changes) to refresh UI
wmeSDK.Events.on({
eventName: 'wme-after-edit',
eventHandler: () => {
if (lastFeatures.length > 0) {
// Refresh the street analysis panel to reflect any street name changes
analyzeStreetMatches();
applyFeatureFilter();
}
}
});
}
setupHouseNumberEventListeners();
function setupNavPoints(tabPane) {
const chkNavPoints = tabPane.querySelector('#qhnsl-navpoints');
if (!chkNavPoints) return;
let lastNavIds = [];
let currentRenderId = 0;
let renderTimer = null;
wmeSDK.Map.addLayer({
layerName: SDK_NAVPOINTS_LAYER_NAME,
zIndexing: true,
styleContext: {
getColor: ({ feature }) => {
const p = feature.properties;
if (p.forced) return p.touched ? '#ff9933' : '#ff3333';
return p.touched ? '#ffffff' : '#ffdd00';
},
getLabel: ({ feature }) => String(feature.properties.number ?? '')
},
styleRules: [
{
predicate: (featureProperties) => featureProperties.kind === 'line',
style: {
strokeColor: '${getColor}',
strokeWidth: 2,
strokeOpacity: 0.9,
strokeDashstyle: 'dash',
fill: false
}
},
{
predicate: (featureProperties) => featureProperties.kind === 'label',
style: {
label: '${getLabel}',
fontColor: '#111111',
fontSize: '12px',
fontWeight: 'bold',
fontFamily: '"Open Sans", Arial, sans-serif',
labelOutlineColor: '${getColor}',
labelOutlineWidth: 3,
labelOutlineOpacity: 1,
pointRadius: 0,
stroke: false,
fill: false
}
}
]
});
function clearNavLayer() {
if (!lastNavIds.length) return;
try {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_NAVPOINTS_LAYER_NAME, featureIds: lastNavIds });
} catch (e) {
console.debug('[SL-HN] NavPoints clearLayer:', e);
}
lastNavIds = [];
}
async function renderNavPoints() {
if (!LS.getNavPoints()) { clearNavLayer(); return; }
if (wmeSDK.Map.getZoomLevel() < 18) { clearNavLayer(); return; }
const myRenderId = ++currentRenderId;
const segIds = wmeSDK.DataModel.Segments.getAll()
.filter(s => s.hasHouseNumbers)
.map(s => s.id);
if (!segIds.length) { clearNavLayer(); return; }
let allHns;
try {
allHns = await wmeSDK.DataModel.HouseNumbers.fetchHouseNumbers({ segmentIds: segIds });
} catch (err) {
console.warn('[SL-HN] NavPoints fetch failed:', err);
return;
}
if (myRenderId !== currentRenderId) return;
const features = [];
for (const hn of allHns) {
const touched = hn.updatedBy != null;
const forced = hn.isForced === true;
if (hn.fractionPoint?.coordinates && hn.geometry?.coordinates) {
features.push({
type: 'Feature',
id: `navp-${hn.id}-line`,
geometry: {
type: 'LineString',
coordinates: [hn.fractionPoint.coordinates, hn.geometry.coordinates]
},
properties: { kind: 'line', touched, forced }
});
}
if (hn.geometry?.coordinates) {
features.push({
type: 'Feature',
id: `navp-${hn.id}-label`,
geometry: hn.geometry,
properties: { kind: 'label', number: hn.number, touched, forced }
});
}
}
if (lastNavIds.length) {
try {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_NAVPOINTS_LAYER_NAME, featureIds: lastNavIds });
} catch (e) {
console.debug('[SL-HN] NavPoints swap-clear:', e);
}
}
if (features.length) {
try {
wmeSDK.Map.addFeaturesToLayer({ layerName: SDK_NAVPOINTS_LAYER_NAME, features });
} catch (e) {
console.warn('[SL-HN] NavPoints addFeaturesToLayer:', e);
lastNavIds = [];
return;
}
}
lastNavIds = features.map(f => f.id);
}
function scheduleRender() {
if (renderTimer) clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
renderTimer = null;
renderNavPoints().catch(err => console.warn('[SL-HN] NavPoints render failed:', err));
}, 300);
}
chkNavPoints.addEventListener('click', () => {
const on = !isChecked(chkNavPoints);
setChecked(chkNavPoints, on);
LS.setNavPoints(on);
if (on) scheduleRender();
else clearNavLayer();
});
if (LS.getNavPoints()) scheduleRender();
const NAVPOINTS_TRIGGER_EVENTS = [
'wme-map-zoom-changed',
'wme-map-move-end',
'wme-house-number-added',
'wme-house-number-deleted',
'wme-house-number-moved',
'wme-house-number-updated',
'wme-map-data-loaded'
];
NAVPOINTS_TRIGGER_EVENTS.forEach(eventName => {
wmeSDK.Events.on({
eventName,
eventHandler: () => {
if (LS.getNavPoints()) scheduleRender();
}
});
});
}
['qhnsl-load', 'qhnsl-clear'].forEach(id => {
try { wmeSDK.Shortcuts.deleteShortcut({ shortcutId: id }); } catch (_) {}
});
[
{ shortcutId: 'qhnsl-load', shortcutKeys: 'AS+l', description: 'SL-HN: Load selected street', callback: loadSelectedStreet },
{ shortcutId: 'qhnsl-clear', shortcutKeys: 'AS+k', description: 'SL-HN: Clear', callback: clearLayer }
].forEach(spec => {
try { wmeSDK.Shortcuts.createShortcut(spec); }
catch (e) { console.warn('SL-HN: failed to register shortcut', spec.shortcutId, e); }
});
function updateLayer(statusDiv, loadId) {
return new Promise((resolve) => {
const selectedSegments = getSelectedSegments();
if (selectedSegments.length === 0) {
toast('Select a segment first.', 'warning');
statusDiv.textContent = 'No segment selected.';
resolve();
return;
}
loading.style.display = null;
// Compute bounding box of selected segments in WGS84 from GeoJSON coords
let minLon = Infinity, maxLon = -Infinity;
let minLat = Infinity, maxLat = -Infinity;
selectedSegments.forEach(seg => {
const coords = seg.geometry?.coordinates;
if (!Array.isArray(coords)) return;
coords.forEach(pt => {
const lon = pt[0], lat = pt[1];
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
});
});
if (minLon === Infinity) {
loading.style.display = 'none';
statusDiv.textContent = 'No geometry for selected segments.';
resolve();
return;
}
// Convert WGS84 bbox to EPSG:3794 (Slovenia D96/TM, in meters), then buffer
const bl = proj4('EPSG:4326', 'EPSG:3794', [minLon, minLat]);
const tr = proj4('EPSG:4326', 'EPSG:3794', [maxLon, maxLat]);
const buffer = LS.getBuffer();
const minE = Math.floor(bl[0] - buffer);
const minN = Math.floor(bl[1] - buffer);
const maxE = Math.ceil(tr[0] + buffer);
const maxN = Math.ceil(tr[1] + buffer);
Promise.all([
fetchAddresses(minE, minN, maxE, maxN),
getVisibleHNsByStreet()
])
.then(([apiFeatures, selectionHNMap]) => {
// Bail out if user clicked Clear (or started a newer load) while the fetch was in flight
if (loadId !== currentLoadId) {
loading.style.display = 'none';
resolve();
return;
}
const features = [];
for (const item of apiFeatures) {
const props = item.properties;
if (!props) continue;
// Skip addresses without coordinates
const e = props.E;
const n = props.N;
if (e == null || n == null) continue;
// Convert from EPSG:3794 to EPSG:4326
const [lon, lat] = proj4('EPSG:3794', 'EPSG:4326', [e, n]);
// Build house number from components
const hn = buildHouseNumber(props.HS_STEVILKA, props.HS_DODATEK);
if (!hn) continue;
// Get street name, or settlement name for villages without streets
const streetName = props.ULICA_NAZIV || props.NASELJE_NAZIV;
if (!streetName) continue;
const streetId = normalizeStreetName(streetName);
if (!streets[streetName]) {
streets[streetName] = streetId;
streetNames[streetId] = streetName;
}
const entry = selectionHNMap.get(streetId);
const processed = entry?.set.has(hn) === true;
const conflict = !processed && hasConflict(hn, e, n, entry);
features.push({
number: hn,
street: streetId,
processed,
conflict,
lon,
lat,
eX: e,
eY: n
});
}
const allStreetIds = new Set();
selectedSegments.forEach(seg => {
(seg.alternateStreetIds || []).forEach(id => allStreetIds.add(id));
if (seg.primaryStreetId) allStreetIds.add(seg.primaryStreetId);
});
const selectedNames = [...allStreetIds]
.map(id => wmeSDK.DataModel.Streets.getById({ streetId: id })?.name)
.filter(Boolean);
let best = null, bestCount = -1;
selectedNames.forEach(name => {
const sid = streets[name];
if (!sid) return;
const count = features.reduce((n,f)=> n + (f.street === sid ? 1 : 0), 0);
if (count > bestCount) { best = sid; bestCount = count; }
});
currentStreetId = best || null;
if (!features.length) {
loading.style.display = 'none';
statusDiv.textContent = 'No address points in view.';
resolve();
return;
}
lastFeatures = features;
if (currentStreetId && streetNames[currentStreetId]) {
streetNameSpan.textContent = streetNames[currentStreetId];
currentStreetDiv.style.display = 'block';
} else {
currentStreetDiv.style.display = 'none';
}
if (lastSdkFeatureIds.length) {
wmeSDK.Map.removeFeaturesFromLayer({ layerName: SDK_LAYER_NAME, featureIds: lastSdkFeatureIds });
lastSdkFeatureIds = [];
}
applyFeatureFilter();
analyzeStreetMatches();
loading.style.display = 'none';
statusDiv.innerHTML = `Loaded ${lastFeatures.length} address points.<br/><b>Click numbers on map to add them!</b><br/>Green = selected • Orange = other • Red = possible wrong HN`;
resolve();
})
.catch(err => {
console.error('[Quick HN Importer] API error:', err);
loading.style.display = 'none';
if (loadId === currentLoadId) {
statusDiv.textContent = 'Error fetching address data. See console.';
toast('Error fetching address data.', 'error');
}
resolve();
});
});
}
// Visible HNs grouped by normalized street name (primary + alternate)
async function getVisibleHNsByStreet() {
const map = new Map();
const ext = wmeSDK.Map.getMapExtent();
const [lonMin, latMin, lonMax, latMax] = Array.isArray(ext)
? ext
: [ext.lonMin, ext.latMin, ext.lonMax, ext.latMax];
const segIds = wmeSDK.DataModel.Segments.getAll()
.filter(s => s.hasHouseNumbers)
.map(s => s.id);
const allHns = segIds.length
? await wmeSDK.DataModel.HouseNumbers.fetchHouseNumbers({ segmentIds: segIds })
: [];
allHns.forEach(hn => {
const seg = wmeSDK.DataModel.Segments.getById({ segmentId: hn.segmentId });
if (!seg) return;
const streetIdSet = new Set();
if (seg.primaryStreetId) {
streetIdSet.add(seg.primaryStreetId);
}
(seg.alternateStreetIds || []).forEach(id => {
if (id) streetIdSet.add(id);
});
if (!streetIdSet.size) return;
const g = getHNGeometry(hn);
let x, y;
if (g && typeof g.x === 'number' && typeof g.y === 'number') {
x = g.x;
y = g.y;
}
if (x == null || y == null || x < lonMin || x > lonMax || y < latMin || y > latMax) return;
const [eX, eY] = proj4('EPSG:4326', 'EPSG:3794', [x, y]);
const numRaw = String(hn.number).trim();
streetIdSet.forEach(streetId => {
const st = wmeSDK.DataModel.Streets.getById({ streetId });
const name = st?.name;
if (!name) return;
const sidNorm = normalizeStreetName(name);
let entry = map.get(sidNorm);
if (!entry) {
entry = { set: new Set(), items: [] };
map.set(sidNorm, entry);
}
entry.set.add(numRaw);
entry.items.push({ num: numRaw, x: eX, y: eY });
});
});
return map;
}
setupNavPoints(tabPane);
});
}
(unsafeWindow || window).SDK_INITIALIZED.then(() => {
wmeSDK = getWmeSdk({ scriptId: 'quick-hn-sl-importer', scriptName: 'Quick HN Importer (SI)' });
wmeSDK.Events.once({ eventName: 'wme-ready' }).then(() => {
const required = [
'DataModel.Segments.getAll',
'DataModel.Segments.getById',
'DataModel.Streets.getAll',
'DataModel.Streets.getById',
'DataModel.Streets.getStreet',
'DataModel.HouseNumbers.fetchHouseNumbers',
'DataModel.HouseNumbers.addHouseNumber',
'DataModel.Segments.updateAddress',
'DataModel.Streets.addStreet',
'Editing.setSelection',
'Editing.getSelection',
'Map.addLayer',
'Map.addFeaturesToLayer',
'Map.removeFeaturesFromLayer',
'Map.setLayerVisibility',
'Map.getZoomLevel',
'Map.getMapExtent',
'Map.getMapPixelFromLonLat'
];
const missing = required.filter(path => {
const parts = path.split('.');
let cur = wmeSDK;
for (const p of parts) { cur = cur?.[p]; if (cur == null) return true; }
return false;
});
if (missing.length) {
console.error('[SL-HN] WME SDK missing required APIs:', missing);
toast(`SL-HN: WME SDK is missing ${missing.length} required APIs. See console.`, 'error');
return;
}
init();
});
});
})();