Files
wifi-gps-mapper-public/reports/search.py
T
2026-10-06 21:08:12 +03:00

2044 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Server-side search for WiFi GPS Mapper reports.
Search criteria are intentionally represented as a dictionary so that
new search blocks can be added without redesigning the search layer.
"""
import json
from html import escape
from reports.utils import (
format_datetime as format_report_datetime,
)
MAP_STYLE_URL = (
"http://127.0.0.1:8080/styles/server-map/style.json"
)
# ============================================================
# Search criteria
# ============================================================
def normalize_criteria(
vendor=None,
):
criteria = {}
if vendor is not None:
vendor = str(vendor).strip()
if vendor:
criteria["vendor"] = vendor
return criteria
def build_where_clause(criteria):
conditions = []
parameters = []
vendor = criteria.get("vendor")
if vendor:
conditions.append(
"""
LOWER(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
COALESCE(ap.vendor, ''),
'-',
''
),
' ',
''
),
'_',
''
),
'.',
''
),
'/',
''
)
) LIKE
'%' ||
LOWER(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(
?,
'-',
''
),
' ',
''
),
'_',
''
),
'.',
''
),
'/',
''
)
) ||
'%'
"""
)
parameters.append(
vendor
)
if not conditions:
raise ValueError(
"At least one search criterion is required."
)
return (
" AND ".join(conditions),
parameters
)
# ============================================================
# Popular vendors
# ============================================================
def get_popular_vendors(
conn,
limit=20,
):
rows = conn.execute(
"""
SELECT
vendor,
COUNT(*) AS count
FROM access_points
WHERE
vendor IS NOT NULL
AND TRIM(vendor) != ''
GROUP BY vendor
ORDER BY
count DESC,
vendor COLLATE NOCASE ASC
LIMIT ?
""",
(
limit,
)
).fetchall()
return [
{
"vendor": row["vendor"],
"count": row["count"],
}
for row in rows
]
def get_matching_vendors(
conn,
criteria,
limit=20,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT
ap.vendor,
COUNT(*) AS count
FROM access_points AS ap
WHERE
{where_clause}
AND ap.vendor IS NOT NULL
AND TRIM(ap.vendor) != ''
GROUP BY
ap.vendor
ORDER BY
count DESC,
ap.vendor COLLATE NOCASE ASC
LIMIT ?
""",
parameters + [limit]
).fetchall()
return [
{
"vendor": row["vendor"],
"count": row["count"],
}
for row in rows
]
# ============================================================
# Access point search
# ============================================================
def search_access_points(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
return conn.execute(
f"""
SELECT
ap.id,
ap.bssid,
ap.essid,
ap.encryption,
ap.cipher,
ap.akm,
ap.country,
ap.channel,
ap.frequency,
ap.vendor,
ap.first_seen,
ap.last_seen,
ap.times_seen,
ap.last_rssi,
ap.last_latitude,
ap.last_longitude,
ap.last_speed,
ap.has_handshake,
ap.has_pmkid,
ap.is_cracked
FROM access_points AS ap
WHERE
{where_clause}
ORDER BY
ap.vendor COLLATE NOCASE,
ap.essid COLLATE NOCASE,
ap.bssid
""",
parameters
).fetchall()
# ============================================================
# Credentials
# ============================================================
def load_credentials(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT
c.access_point_id,
c.password,
c.source,
c.created_at,
c.verified
FROM credentials AS c
INNER JOIN access_points AS ap
ON ap.id = c.access_point_id
WHERE
{where_clause}
ORDER BY
c.access_point_id,
c.password
""",
parameters
).fetchall()
result = {}
for row in rows:
access_point_id = row["access_point_id"]
result.setdefault(
access_point_id,
[]
).append(
{
"password": row["password"],
"source": row["source"],
"created_at": row["created_at"],
"verified": bool(
row["verified"]
),
}
)
return result
# ============================================================
# Handshakes / PMKIDs
# ============================================================
def load_handshakes(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT
h.id,
h.access_point_id,
h.type,
h.hash22000,
h.message_pair,
h.captured_at
FROM handshakes AS h
INNER JOIN access_points AS ap
ON ap.id = h.access_point_id
WHERE
{where_clause}
ORDER BY
h.access_point_id,
h.captured_at
""",
parameters
).fetchall()
result = {}
for row in rows:
access_point_id = row["access_point_id"]
result.setdefault(
access_point_id,
[]
).append(
{
"id": row["id"],
"type": row["type"],
"hash22000": row["hash22000"],
"message_pair": row["message_pair"],
"captured_at": row["captured_at"],
}
)
return result
# ============================================================
# Category
# ============================================================
def get_category(
access_point,
):
if access_point["is_cracked"]:
return "password"
if (
access_point["has_handshake"]
or
access_point["has_pmkid"]
):
return "handshake"
return "other"
# ============================================================
# Popup helpers
# ============================================================
def safe(value):
if value is None:
return "—"
text = str(value)
if not text:
return "—"
return escape(
text,
quote=True
)
def format_security(
access_point,
):
parts = []
if access_point["encryption"]:
parts.append(
access_point["encryption"]
)
if access_point["cipher"]:
parts.append(
access_point["cipher"]
)
if access_point["akm"]:
parts.append(
access_point["akm"]
)
if not parts:
return "—"
return escape(
" / ".join(
str(item)
for item in parts
),
quote=True
)
def popup_password(
access_point,
credentials,
):
password_items = []
for credential in credentials:
verified = (
"Yes"
if credential["verified"]
else
"No"
)
password_items.append(
"""
<div class="search-popup-password-item">
<div class="search-popup-password">
{password}
</div>
<div class="search-popup-meta">
Verified: {verified}
&nbsp;·&nbsp;
Source: {source}
</div>
</div>
""".format(
password=safe(
credential["password"]
),
verified=verified,
source=safe(
credential["source"]
)
)
)
if not password_items:
password_items.append(
"""
<div class="search-popup-muted">
No password details available.
</div>
"""
)
return """
<div class="search-popup">
<div class="search-popup-title">
{essid}
</div>
<div class="search-popup-line">
<strong>BSSID:</strong>
{bssid}
</div>
<div class="search-popup-line">
<strong>Vendor:</strong>
{vendor}
</div>
<div class="search-popup-category password">
PASSWORD
</div>
<div class="search-popup-section">
{passwords}
</div>
<div class="search-popup-section">
<strong>Security:</strong>
{security}
</div>
<div class="search-popup-line">
<strong>Channel:</strong>
{channel}
&nbsp;·&nbsp;
<strong>Frequency:</strong>
{frequency}
</div>
<div class="search-popup-line">
<strong>RSSI:</strong>
{rssi}
</div>
<div class="search-popup-line">
<strong>First seen:</strong>
{first_seen}
</div>
<div class="search-popup-line">
<strong>Last seen:</strong>
{last_seen}
</div>
<div class="search-popup-line">
<strong>Times seen:</strong>
{times_seen}
</div>
</div>
""".format(
essid=safe(
access_point["essid"]
),
bssid=safe(
access_point["bssid"]
),
vendor=safe(
access_point["vendor"]
),
passwords="".join(
password_items
),
security=format_security(
access_point
),
channel=safe(
access_point["channel"]
),
frequency=safe(
access_point["frequency"]
),
rssi=safe(
access_point["last_rssi"]
),
first_seen=format_report_datetime(
access_point["first_seen"]
),
last_seen=format_report_datetime(
access_point["last_seen"]
),
times_seen=safe(
access_point["times_seen"]
)
)
def popup_handshake(
access_point,
handshakes,
):
eapol_count = 0
pmkid_count = 0
capture_times = []
for handshake in handshakes:
handshake_type = (
str(
handshake["type"]
or ""
).upper()
)
if "PMKID" in handshake_type:
pmkid_count += 1
else:
eapol_count += 1
if handshake["captured_at"]:
capture_times.append(
format_report_datetime(
handshake["captured_at"]
)
)
summary = []
if eapol_count:
summary.append(
f"EAPOL × {eapol_count}"
)
if pmkid_count:
summary.append(
f"PMKID × {pmkid_count}"
)
if not summary:
summary.append(
"Handshake / PMKID"
)
capture_html = ""
if capture_times:
capture_html = """
<div class="search-popup-section">
<strong>Capture time:</strong>
<div class="search-popup-list">
{times}
</div>
</div>
""".format(
times="".join(
"<div>{}</div>".format(
safe(timestamp)
)
for timestamp in capture_times
)
)
return """
<div class="search-popup">
<div class="search-popup-title">
{essid}
</div>
<div class="search-popup-line">
<strong>BSSID:</strong>
{bssid}
</div>
<div class="search-popup-line">
<strong>Vendor:</strong>
{vendor}
</div>
<div class="search-popup-category handshake">
HANDSHAKE / PMKID
</div>
<div class="search-popup-section">
<strong>Material:</strong>
{summary}
</div>
{capture_html}
<div class="search-popup-section">
<strong>Security:</strong>
{security}
</div>
<div class="search-popup-line">
<strong>Channel:</strong>
{channel}
&nbsp;·&nbsp;
<strong>Frequency:</strong>
{frequency}
</div>
<div class="search-popup-line">
<strong>RSSI:</strong>
{rssi}
</div>
<div class="search-popup-line">
<strong>First seen:</strong>
{first_seen}
</div>
<div class="search-popup-line">
<strong>Last seen:</strong>
{last_seen}
</div>
<div class="search-popup-line">
<strong>Times seen:</strong>
{times_seen}
</div>
</div>
""".format(
essid=safe(
access_point["essid"]
),
bssid=safe(
access_point["bssid"]
),
vendor=safe(
access_point["vendor"]
),
summary=escape(
" · ".join(summary)
),
capture_html=capture_html,
security=format_security(
access_point
),
channel=safe(
access_point["channel"]
),
frequency=safe(
access_point["frequency"]
),
rssi=safe(
access_point["last_rssi"]
),
first_seen=format_report_datetime(
access_point["first_seen"]
),
last_seen=format_report_datetime(
access_point["last_seen"]
),
times_seen=safe(
access_point["times_seen"]
)
)
def popup_other(
access_point,
):
return """
<div class="search-popup">
<div class="search-popup-title">
{essid}
</div>
<div class="search-popup-line">
<strong>BSSID:</strong>
{bssid}
</div>
<div class="search-popup-line">
<strong>Vendor:</strong>
{vendor}
</div>
<div class="search-popup-category other">
OTHER AP
</div>
<div class="search-popup-section">
<strong>Security:</strong>
{security}
</div>
<div class="search-popup-line">
<strong>Channel:</strong>
{channel}
&nbsp;·&nbsp;
<strong>Frequency:</strong>
{frequency}
</div>
<div class="search-popup-line">
<strong>RSSI:</strong>
{rssi}
</div>
<div class="search-popup-line">
<strong>First seen:</strong>
{first_seen}
</div>
<div class="search-popup-line">
<strong>Last seen:</strong>
{last_seen}
</div>
<div class="search-popup-line">
<strong>Times seen:</strong>
{times_seen}
</div>
</div>
""".format(
essid=safe(
access_point["essid"]
),
bssid=safe(
access_point["bssid"]
),
vendor=safe(
access_point["vendor"]
),
security=format_security(
access_point
),
channel=safe(
access_point["channel"]
),
frequency=safe(
access_point["frequency"]
),
rssi=safe(
access_point["last_rssi"]
),
first_seen=format_report_datetime(
access_point["first_seen"]
),
last_seen=format_report_datetime(
access_point["last_seen"]
),
times_seen=safe(
access_point["times_seen"]
)
)
# ============================================================
# GeoJSON
# ============================================================
def build_geojson(
access_points,
credentials,
handshakes,
):
features = []
for access_point in access_points:
latitude = access_point[
"last_latitude"
]
longitude = access_point[
"last_longitude"
]
if latitude is None or longitude is None:
continue
if (
latitude == 0
and
longitude == 0
):
continue
access_point_id = (
access_point["id"]
)
category = get_category(
access_point
)
if category == "password":
popup = popup_password(
access_point,
credentials.get(
access_point_id,
[]
)
)
elif category == "handshake":
popup = popup_handshake(
access_point,
handshakes.get(
access_point_id,
[]
)
)
else:
popup = popup_other(
access_point
)
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
float(longitude),
float(latitude)
]
},
"properties": {
"id": access_point_id,
"category": category,
"popup": popup,
}
}
)
return {
"type": "FeatureCollection",
"features": features
}
# ============================================================
# Hash export
# ============================================================
def get_hashes(
conn,
criteria,
):
where_clause, parameters = (
build_where_clause(criteria)
)
rows = conn.execute(
f"""
SELECT DISTINCT
h.hash22000
FROM handshakes AS h
INNER JOIN access_points AS ap
ON ap.id = h.access_point_id
WHERE
{where_clause}
AND h.hash22000 IS NOT NULL
AND TRIM(h.hash22000) != ''
ORDER BY
h.hash22000
""",
parameters
).fetchall()
return [
row["hash22000"]
for row in rows
]
def build_hash_export(
conn,
criteria,
):
hashes = get_hashes(
conn,
criteria
)
if not hashes:
return ""
return (
"\n".join(
str(item)
for item in hashes
)
+
"\n"
)
# ============================================================
# Search data
# ============================================================
def build_search_data(
conn,
criteria,
):
access_points = (
search_access_points(
conn,
criteria
)
)
credentials = (
load_credentials(
conn,
criteria
)
)
handshakes = (
load_handshakes(
conn,
criteria
)
)
geojson = build_geojson(
access_points,
credentials,
handshakes
)
hashes = get_hashes(
conn,
criteria
)
password_access_points = sum(
1
for access_point in access_points
if access_point["is_cracked"]
)
handshake_access_points = sum(
1
for access_point in access_points
if (
access_point["has_handshake"]
or
access_point["has_pmkid"]
)
)
matching_vendors = (
get_matching_vendors(
conn,
criteria
)
)
return {
"criteria": criteria,
"matched_access_points": len(
access_points
),
"mapped_access_points": len(
geojson["features"]
),
"password_access_points":
password_access_points,
"handshake_access_points":
handshake_access_points,
"hash_count": len(hashes),
"matching_vendors":
matching_vendors,
"geojson": geojson,
}
# ============================================================
# Search page
# ============================================================
def render_search_page(
conn,
context=None,
):
from reports.template import (
page_begin,
page_end,
)
if context is None:
from reports.template import (
ReportContext,
)
context = ReportContext(
mode="server"
)
maplibre_css_url = context.asset_url(
"maplibre/maplibre-gl.css"
)
popular_vendors = (
get_popular_vendors(conn)
)
vendor_items = []
for item in popular_vendors:
vendor = escape(
str(
item["vendor"]
),
quote=True
)
count = int(
item["count"]
)
vendor_items.append(
"""
<div class="search-vendor-item">
<span class="search-vendor-name">
{vendor}
</span>
<span class="search-vendor-count">
{count}
</span>
</div>
""".format(
vendor=vendor,
count=count
)
)
if vendor_items:
suggestions_html = "".join(
vendor_items
)
else:
suggestions_html = """
<span class="search-popup-muted">
No vendors available.
</span>
"""
html = page_begin(
"Search",
"Search access points in the WiFi GPS Mapper database.",
"search",
context
)
html += """
<section class="card search-card">
<h2>
Search by Vendor
</h2>
<form
id="search-form"
class="search-form"
>
<label
for="search-vendor"
class="search-label"
>
Vendor
</label>
<div class="search-input-row">
<input
id="search-vendor"
name="vendor"
type="text"
class="search-input"
autocomplete="off"
placeholder="e.g. Intel Corporation"
>
<button
type="submit"
class="button"
>
Найти
</button>
</div>
</form>
<div class="search-suggestions">
<div class="search-suggestions-title">
Popular vendors
</div>
<div
id="search-vendor-suggestions"
class="search-vendor-suggestions"
>
{suggestions}
</div>
</div>
</section>
<section class="card search-card search-future">
<h2>
Search by Access Point
</h2>
<div class="search-future-content">
Reserved for future search criteria:
ESSID, BSSID and other access point attributes.
</div>
</section>
<section
id="search-results"
class="card search-card search-results"
hidden
>
<div class="search-results-header">
<div>
<h2>
Search results
</h2>
<div
id="search-query"
class="search-query"
></div>
<div
id="search-status"
class="search-status"
></div>
</div>
<button
id="search-export"
type="button"
class="button"
disabled
>
Export hashes
</button>
</div>
<div
id="search-vendors"
class="search-matched-vendors"
hidden
></div>
<div
id="search-map-controls"
class="search-map-controls"
>
<label>
<input
type="checkbox"
data-category="password"
checked
>
<span
class="map-layer-dot map-layer-dot-password"
></span>
Password
</label>
<label>
<input
type="checkbox"
data-category="handshake"
checked
>
<span
class="map-layer-dot map-layer-dot-handshake"
></span>
Handshake / PMKID
</label>
<label>
<input
type="checkbox"
data-category="other"
checked
>
<span
class="map-layer-dot map-layer-dot-other"
></span>
Other APs
</label>
</div>
<div
id="search-map"
class="search-map"
></div>
<div
id="search-map-empty"
class="search-map-empty"
hidden
>
No matching access points with valid coordinates.
</div>
</section>
<link
rel="stylesheet"
href="{maplibre_css}"
>
<script src="{maplibre_js}"></script>
<script>
(() => {
const form = document.getElementById(
"search-form"
);
const vendorInput = document.getElementById(
"search-vendor"
);
const results = document.getElementById(
"search-results"
);
const query = document.getElementById(
"search-query"
);
const status = document.getElementById(
"search-status"
);
const exportButton = document.getElementById(
"search-export"
);
const matchedVendors = document.getElementById(
"search-vendors"
);
const mapContainer = document.getElementById(
"search-map"
);
const mapEmpty = document.getElementById(
"search-map-empty"
);
const suggestions = document.querySelectorAll(
".search-vendor-suggestion"
);
let map = null;
let searchParams = null;
const layerNames = {
password:
"search-password",
handshake:
"search-handshake",
other:
"search-other"
};
function setStatus(text) {
status.textContent = text;
}
function updateLayerVisibility(
category,
visible
) {
if (!map) {
return;
}
const layerId =
layerNames[category];
if (!map.getLayer(layerId)) {
return;
}
map.setLayoutProperty(
layerId,
"visibility",
visible
? "visible"
: "none"
);
}
function createMap(
geojson
) {
if (map) {
map.remove();
map = null;
}
if (
!geojson.features
||
geojson.features.length === 0
) {
mapContainer.hidden = true;
mapEmpty.hidden = false;
return;
}
mapContainer.hidden = false;
mapEmpty.hidden = true;
map = new maplibregl.Map({
container:
"search-map",
style:
"{map_style}",
center: [
geojson.features[0]
.geometry
.coordinates[0],
geojson.features[0]
.geometry
.coordinates[1]
],
zoom: 12
});
map.addControl(
new maplibregl.NavigationControl({
showZoom: true,
showCompass: true
}),
"top-right"
);
map.on(
"load",
() => {
map.addSource(
"search-aps",
{
type: "geojson",
data: geojson
}
);
map.addLayer(
{
id:
"search-password",
type:
"circle",
source:
"search-aps",
filter: [
"==",
[
"get",
"category"
],
"password"
],
paint: {
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-color":
"#45d483",
"circle-stroke-color":
"#0f141a"
}
}
);
map.addLayer(
{
id:
"search-handshake",
type:
"circle",
source:
"search-aps",
filter: [
"==",
[
"get",
"category"
],
"handshake"
],
paint: {
"circle-radius": 7,
"circle-stroke-width": 2,
"circle-color":
"#e5c07b",
"circle-stroke-color":
"#0f141a"
}
}
);
map.addLayer(
{
id:
"search-other",
type:
"circle",
source:
"search-aps",
filter: [
"==",
[
"get",
"category"
],
"other"
],
paint: {
"circle-radius": 6,
"circle-stroke-width": 1,
"circle-color":
"#69a7ff",
"circle-stroke-color":
"#0f141a"
}
}
);
document.querySelectorAll(
"#search-map-controls input"
).forEach(
(checkbox) => {
updateLayerVisibility(
checkbox.dataset.category,
checkbox.checked
);
}
);
[
"search-password",
"search-handshake",
"search-other"
].forEach(
(layerId) => {
let hoverPopup = null;
let hoverFeatureId = null;
map.on(
"mousemove",
layerId,
(event) => {
const feature =
event.features[0];
if (!feature) {
return;
}
map.getCanvas()
.style
.cursor = "pointer";
const featureId =
feature.properties.id;
if (
hoverFeatureId ===
featureId
) {
return;
}
hoverFeatureId =
featureId;
if (hoverPopup) {
hoverPopup.remove();
}
hoverPopup =
new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
offset: 10
})
.setLngLat(
event.lngLat
)
.setHTML(
feature
.properties
.popup
)
.addTo(map);
}
);
map.on(
"mouseleave",
layerId,
() => {
map.getCanvas()
.style
.cursor = "";
hoverFeatureId = null;
if (hoverPopup) {
hoverPopup.remove();
hoverPopup = null;
}
}
);
map.on(
"click",
layerId,
(event) => {
const feature =
event.features[0];
if (!feature) {
return;
}
if (hoverPopup) {
hoverPopup.remove();
hoverPopup = null;
}
hoverFeatureId = null;
new maplibregl.Popup({
closeButton: true,
closeOnClick: true,
offset: 10
})
.setLngLat(
event.lngLat
)
.setHTML(
feature
.properties
.popup
)
.addTo(map);
}
);
}
);
const bounds =
new maplibregl.LngLatBounds();
geojson.features.forEach(
(feature) => {
bounds.extend(
feature
.geometry
.coordinates
);
}
);
if (
geojson.features.length === 1
) {
map.setCenter(
geojson.features[0]
.geometry
.coordinates
);
map.setZoom(15);
} else {
map.fitBounds(
bounds,
{
padding: 60,
maxZoom: 15
}
);
}
}
);
}
suggestions.forEach(
(button) => {
button.addEventListener(
"click",
() => {
vendorInput.value =
button.dataset.vendor;
vendorInput.focus();
}
);
}
);
document.querySelectorAll(
"#search-map-controls input"
).forEach(
(checkbox) => {
checkbox.addEventListener(
"change",
() => {
updateLayerVisibility(
checkbox.dataset.category,
checkbox.checked
);
}
);
}
);
exportButton.addEventListener(
"click",
() => {
if (!searchParams) {
return;
}
const url =
"/reports/search/export?"
+
searchParams.toString();
window.location.href = url;
}
);
form.addEventListener(
"submit",
async (event) => {
event.preventDefault();
const vendor =
vendorInput.value.trim();
if (!vendor) {
setStatus(
"Enter a vendor name."
);
query.textContent = "";
matchedVendors.innerHTML = "";
matchedVendors.hidden = true;
results.hidden = false;
exportButton.disabled = true;
if (map) {
map.remove();
map = null;
}
mapContainer.hidden = true;
mapEmpty.hidden = true;
return;
}
searchParams =
new URLSearchParams();
searchParams.set(
"vendor",
vendor
);
results.hidden = false;
setStatus(
"Searching..."
);
exportButton.disabled = true;
try {
const response =
await fetch(
"/reports/search/data?"
+
searchParams.toString()
);
const data =
await response.json();
if (!response.ok) {
throw new Error(
data.error
||
"Search failed."
);
}
query.textContent =
"Query: " +
vendor;
setStatus(
"Matched "
+
data.matched_access_points
+
" access point(s), "
+
data.mapped_access_points
+
" mapped. "
+
"Password: "
+
data.password_access_points
+
" AP(s). "
+
"Handshake / PMKID: "
+
data.handshake_access_points
+
" AP(s). "
+
"Unique hashes: "
+
data.hash_count
+
"."
);
matchedVendors.innerHTML = "";
if (
data.matching_vendors
&&
data.matching_vendors.length
) {
const title =
document.createElement(
"div"
);
title.className =
"search-matched-vendors-title";
title.textContent =
"Matched vendors";
matchedVendors.appendChild(
title
);
const list =
document.createElement(
"div"
);
list.className =
"search-matched-vendors-list";
data.matching_vendors.forEach(
(item) => {
const row =
document.createElement(
"div"
);
row.className =
"search-matched-vendor";
const name =
document.createElement(
"span"
);
name.textContent =
item.vendor;
const count =
document.createElement(
"span"
);
count.textContent =
item.count;
row.appendChild(
name
);
row.appendChild(
count
);
list.appendChild(
row
);
}
);
matchedVendors.appendChild(
list
);
matchedVendors.hidden =
false;
} else {
matchedVendors.innerHTML = "";
matchedVendors.hidden = true;
}
exportButton.disabled =
data.hash_count === 0;
createMap(
data.geojson
);
} catch (error) {
setStatus(
error.message
);
query.textContent = "";
matchedVendors.innerHTML = "";
matchedVendors.hidden = true;
exportButton.disabled =
true;
if (map) {
map.remove();
map = null;
}
mapContainer.hidden = true;
mapEmpty.hidden = true;
}
}
);
})();
</script>
""".replace(
"{suggestions}",
suggestions_html
).replace(
"{maplibre_css}",
maplibre_css_url
).replace(
"{maplibre_js}",
context.asset_url(
"maplibre/maplibre-gl.js"
)
).replace(
"{map_style}",
MAP_STYLE_URL
)
html += page_end()
return html