WME Boundary Tools (WKTMap + Polygons) - Casper Edition

Multi-select boundary search + export ONE combined file (GeoJSON/WKT/POLY/WKT-ALL-DL). Stable (no Turf).

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         WME Boundary Tools (WKTMap + Polygons) - Casper Edition
// @namespace    casper/wme-boundary-tools
// @version      1.3.2
// @description  Multi-select boundary search + export ONE combined file (GeoJSON/WKT/POLY/WKT-ALL-DL). Stable (no Turf).
// @author       Casper
// @license      MIT
// @match        https://www.waze.com/editor*
// @match        https://www.waze.com/*/editor*
// @match        https://beta.waze.com/editor*
// @match        https://beta.waze.com/*/editor*
// @grant        GM_info
// @grant        GM.xmlHttpRequest
// @grant        GM_xmlhttpRequest
// @grant        GM_setClipboard
// @require      https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.js?version=latest
// @connect      nominatim.openstreetmap.org
// @connect      polygons.openstreetmap.fr
// ==/UserScript==


(async () => {
  'use strict';

  // -------------------- bootstrap --------------------
  let sdk;
  try {
    sdk = await bootstrap();
    if (!sdk?.Sidebar) throw new Error('WME SDK Sidebar not available.');
  } catch (e) {
    console.error('WME Boundary Tools: bootstrap failed', e);
    return;
  }

  // -------------------- constants --------------------
  // ✅ updated key to avoid mixing old settings
  const STORAGE_KEY = 'casper_wme_boundary_tools_settings_v3_2';

  const POLY_BASE = 'https://polygons.openstreetmap.fr/';
  const WKT_URL   = (relId) => `${POLY_BASE}get_wkt.py?id=${relId}&params=0`;
  const GEOJSON   = (relId) => `${POLY_BASE}get_geojson.py?id=${relId}&params=0`;
  const POLY      = (relId) => `${POLY_BASE}get_poly.py?id=${relId}&params=0`;
  const IMG_PNG   = (relId) => `${POLY_BASE}get_image.py?id=${relId}&params=0`;

  const WKT_MAP_HOME = () => `https://wktmap.com/`;

  // ✅ auto version from GM_info
  const VERSION = (typeof GM_info !== 'undefined' && GM_info?.script?.version)
    ? GM_info.script.version
    : '1.3.2';

  // -------------------- i18n --------------------
  const I18N = {
    en: {
      tab: 'Boundary Tools',
      title: 'WME Boundary Tools',
      subtitle: 'Multi-select + export ONE combined file (GeoJSON/WKT/POLY).',
      lang: 'Language',
      level: 'Boundary level',
      level_country: 'Country',
      level_state: 'State/Province',
      level_city: 'City',
      query: 'Search name',
      query_ph: 'Example: Iraq / Baghdad / Basra / الأنبار ...',
      search: 'Search',
      batch: 'Batch search (one per line)',
      batch_ph: 'Iraq\nBaghdad Iraq\nBasra Iraq\nالأنبار العراق',
      batch_btn: 'Batch Search',
      results: 'Results',
      selected: 'Selected',
      export_one: 'Export Combined (ONE file)',
      exp_geo_all: 'Download GeoJSON (ALL)',
      exp_wkt_all: 'Copy WKT (ALL)',
      exp_wkt_all_dl: 'Download WKT (ALL)',
      exp_wkt_open: 'Copy WKT then open WKTMap',
      exp_poly: 'Download POLY (Combined)',
      status: 'Status',
      status_ready: 'Ready.',
      status_searching: 'Searching…',
      status_no_results: 'No results.',
      status_need_pick: 'Select at least one item.',
      status_fetch_geo: 'Fetching GeoJSON…',
      status_fetch_wkt: 'Fetching WKT…',
      status_fetch_poly: 'Fetching POLY…',
      status_done: 'Done.',
      status_copied: 'Copied.',
      status_opened: 'Opened.',
      status_dl: 'Download started.',
      footer: 'Developed by Casper'
    },
    ar: {
      tab: 'الحدود',
      title: 'أداة الحدود',
      subtitle: 'تحديد متعدد + تصدير ملف واحد (GeoJSON/WKT/POLY).',
      lang: 'اللغة',
      level: 'مستوى الحدود',
      level_country: 'دولة',
      level_state: 'ولاية/محافظة',
      level_city: 'مدينة',
      query: 'اسم البحث',
      query_ph: 'مثال: العراق / بغداد / البصرة / الأنبار ...',
      search: 'بحث',
      batch: 'بحث جماعي (كل سطر اسم)',
      batch_ph: 'العراق\nبغداد العراق\nالبصرة العراق\nالأنبار العراق',
      batch_btn: 'بحث جماعي',
      results: 'النتائج',
      selected: 'المحدد',
      export_one: 'تصدير ملف واحد (مجمع)',
      exp_geo_all: 'تنزيل GeoJSON (الكل)',
      exp_wkt_all: 'نسخ WKT (الكل)',
      exp_wkt_all_dl: 'تنزيل WKT (الكل)',
      exp_wkt_open: 'نسخ WKT ثم فتح WKTMap',
      exp_poly: 'تنزيل POLY (مجمع)',
      status: 'الحالة',
      status_ready: 'جاهز.',
      status_searching: 'جارِ البحث…',
      status_no_results: 'ماكو نتائج.',
      status_need_pick: 'حدد عنصر واحد على الأقل.',
      status_fetch_geo: 'جارِ جلب GeoJSON…',
      status_fetch_wkt: 'جارِ جلب WKT…',
      status_fetch_poly: 'جارِ جلب POLY…',
      status_done: 'تم.',
      status_copied: 'تم النسخ.',
      status_opened: 'تم الفتح.',
      status_dl: 'بدأ التنزيل.',
      footer: 'تطوير: Casper'
    }
  };

  // -------------------- settings --------------------
  const DEFAULTS = Object.freeze({ lang: 'en', level: 'country', lastQuery: '', lastBatch: '' });
  let S = { ...DEFAULTS };

  const loadSettings = () => {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) S = { ...DEFAULTS, ...JSON.parse(raw) };
    } catch { S = { ...DEFAULTS }; }
  };
  const saveSettings = () => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(S)); } catch {} };
  loadSettings();

  const t = (k) => (I18N[S.lang]?.[k] ?? I18N.en[k] ?? k);

  // -------------------- helpers --------------------
  const el = (tag, props = {}, children = []) => {
    const n = document.createElement(tag);
    Object.entries(props).forEach(([k, v]) => {
      if (k === 'class') n.className = v;
      else if (k === 'html') n.innerHTML = v;
      else if (k === 'text') n.textContent = v;
      else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v);
      else n.setAttribute(k, v);
    });
    children.forEach(c => n.appendChild(c));
    return n;
  };

  const escapeHtml = (s) =>
    String(s ?? '')
      .replaceAll('&', '&')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll("'", '&#039;');

  const setStatus = (msg, isError = false) => {
    const box = document.getElementById('cwt-status');
    if (!box) return;
    box.textContent = msg;
    box.style.color = isError ? '#b00020' : '#2b2b2b';
  };

const copyToClipboard = async (text) => {
  const str = String(text ?? '');

  // 1) Tampermonkey way (الأوثق)
  try {
    if (typeof GM_setClipboard === 'function') {
      GM_setClipboard(str, 'text');
      setStatus(t('status_copied'));
      return true;
    }
  } catch {}

  // 2) Browser clipboard
  try {
    await navigator.clipboard.writeText(str);
    setStatus(t('status_copied'));
    return true;
  } catch {}

  // 3) Old fallback
  try {
    const ta = document.createElement('textarea');
    ta.value = str;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
    setStatus(t('status_copied'));
    return true;
  } catch {
    setStatus('Copy failed.', true);
    return false;
  }
};


  const openNewTab = (url) => {
    window.open(url, '_blank', 'noopener,noreferrer');
    setStatus(t('status_opened'));
  };

  const downloadTextFile = (text, filename, mime = 'text/plain;charset=utf-8') => {
    const blob = new Blob([text], { type: mime });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 2000);
    setStatus(t('status_dl'));
  };

  // ✅ تنزيل حقيقي من URL (بدون فتح صفحة نص) — يدعم WKT/GeoJSON/POLY/PNG
  const downloadBinaryViaGM = (url, filename, mime = 'application/octet-stream', timeoutMs = 30000) => new Promise((resolve, reject) => {
    const xhr = (typeof GM !== 'undefined' && GM?.xmlHttpRequest)
  ? GM.xmlHttpRequest
  : (typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null);
    if (!xhr) return reject(new Error('GM XHR unavailable'));

    xhr({
      method: 'GET',
      url,
      responseType: 'arraybuffer',
      timeout: timeoutMs,
      onload: (res) => {
        if (res.status < 200 || res.status >= 300) return reject(new Error('HTTP ' + res.status));
        const blob = new Blob([res.response], { type: mime });
        const blobUrl = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = blobUrl;
        a.download = filename || 'download';
        document.body.appendChild(a);
        a.click();
        a.remove();

        setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
        setStatus(t('status_dl'));
        resolve(true);
      },
      onerror: () => reject(new Error('network error')),
      ontimeout: () => reject(new Error('timeout'))
    });
  });

  const gmFetchText = (url, timeoutMs = 25000) => new Promise((resolve, reject) => {
  const xhr = (typeof GM !== 'undefined' && GM?.xmlHttpRequest)
  ? GM.xmlHttpRequest
  : (typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null);
  if (!xhr) return reject(new Error('GM XHR unavailable'));

    xhr({
      method: 'GET',
      url,
      headers: { 'Accept': '*/*' },
      timeout: timeoutMs,
      onload: (res) => {
        if (res.status >= 200 && res.status < 300) resolve(res.responseText || '');
        else reject(new Error('HTTP ' + res.status));
      },
      onerror: () => reject(new Error('network error')),
      ontimeout: () => reject(new Error('timeout'))
    });
  });

  const gmFetchJson = async (url) => JSON.parse(await gmFetchText(url, 20000));

  // GeoJSON -> WKT (Polygon/MultiPolygon + GeometryCollection)
  const geojsonToWkt = (geom) => {
    const ringToStr = (ring) => ring.map(pt => `${pt[0]} ${pt[1]}`).join(', ');
    const polyToStr = (poly) => `(${poly.map(r => `(${ringToStr(r)})`).join(', ')})`;

    if (!geom) return '';
    if (geom.type === 'Polygon') return `POLYGON${polyToStr(geom.coordinates)}`;
    if (geom.type === 'MultiPolygon') return `MULTIPOLYGON(${geom.coordinates.map(p => polyToStr(p)).join(', ')})`;
    if (geom.type === 'GeometryCollection') {
      const parts = geom.geometries.map(g => geojsonToWkt(g)).filter(Boolean);
      return `GEOMETRYCOLLECTION(${parts.join(', ')})`;
    }
    return '';
  };

  // -------------------- Nominatim search --------------------
  const nominatimSearch = async (q, level) => {
    const url =
      'https://nominatim.openstreetmap.org/search' +
      '?format=jsonv2&addressdetails=1&extratags=1&namedetails=1&limit=15' +
      '&accept-language=' + encodeURIComponent('en') +
      '&q=' + encodeURIComponent(q);

    const data = await gmFetchJson(url);

    const want = (item) => {
      const typeOk = item.osm_type === 'relation';
      const adminOk = item?.extratags?.boundary === 'administrative' || item?.extratags?.admin_level;
      const admin = parseInt(item?.extratags?.admin_level || '', 10);

      const levelHintOk = (() => {
        if (!admin || Number.isNaN(admin)) return true;
        if (level === 'country') return admin <= 4;
        if (level === 'state') return admin >= 3 && admin <= 6;
        if (level === 'city') return admin >= 7 && admin <= 10;
        return true;
      })();

      return typeOk && adminOk && levelHintOk;
    };

    const filtered = Array.isArray(data) ? data.filter(want) : [];
    const fallback = Array.isArray(data) ? data.filter(d => d.osm_type === 'relation') : [];
    return (filtered.length ? filtered : fallback).slice(0, 12);
  };

  // -------------------- UI styles --------------------
  const injectCss = () => {
    const css = `
      #cwt-root{font-size:13px; padding:10px}
      #cwt-root *{box-sizing:border-box}
      #cwt-title{font-weight:900; font-size:14px; margin-bottom:4px}
      #cwt-sub{font-size:11px; color:#666; margin-bottom:10px; line-height:1.35}
      #cwt-row{display:flex; gap:8px; margin:6px 0}
      #cwt-row > div{flex:1}
      #cwt-root label{display:block; font-size:11px; color:#444; margin:0 0 4px 0}
      #cwt-root select, #cwt-root input, #cwt-root textarea{
        width:100%; padding:8px; border:1px solid #d6d6d6; border-radius:10px; font-size:12px; outline:none; background:#fff;
      }
      #cwt-root textarea{min-height:80px; resize:vertical}
      #cwt-root button{
        width:100%; padding:10px; border:0; border-radius:12px; cursor:pointer; font-weight:900; font-size:12px;
      }
      #cwt-searchBtn{background:#1f6feb; color:#fff}
      #cwt-batchBtn{background:#0b1320; color:#fff; margin-top:6px}
      #cwt-blockTitle{font-weight:900; font-size:12px; margin:10px 0 6px 0}
      #cwt-resultsList, #cwt-selectedList{border:1px solid #e7e7e7; border-radius:12px; overflow:hidden; background:#fff}
      .cwt-item{padding:8px 10px; border-bottom:1px solid #f0f0f0}
      .cwt-item:last-child{border-bottom:0}
      .cwt-itemHead{display:flex; align-items:flex-start; justify-content:space-between; gap:8px}
      .cwt-item strong{display:block; font-size:12px}
      .cwt-meta{font-size:10px; color:#666; margin-top:2px}
      .cwt-addBtn{border:1px solid #d7e2ff; background:#f3f7ff; color:#0b3b9f; font-weight:900; padding:6px 8px; border-radius:10px; cursor:pointer; white-space:nowrap}
      .cwt-xBtn{border:1px solid #ffd1d9; background:#fff5f7; color:#a20b2a; font-weight:900; padding:6px 8px; border-radius:10px; cursor:pointer; white-space:nowrap}
      .cwt-actionsRow{display:flex; gap:6px; flex-wrap:wrap; margin-top:8px}
      .cwt-ico{border:1px solid #e6e6e6; background:#f8f8f8; padding:7px 10px; border-radius:12px; cursor:pointer; font-weight:900; font-size:12px; user-select:none}
      .cwt-ico:hover{background:#eef5ff; border-color:#cfe0ff}
      .cwt-ico.dl{background:#f5fffb}
      .cwt-ico.dl:hover{background:#e8fff6}
      .cwt-exportBox{border:1px dashed #cfd8e3; background:#fbfdff; border-radius:12px; padding:10px; margin:10px 0}
      .cwt-exportRow{display:flex; gap:6px; flex-wrap:wrap}
      #cwt-statusWrap{margin-top:10px; padding:8px 10px; border-radius:12px; background:#f6f7f9; border:1px solid #e6e6e6}
      #cwt-statusTitle{font-size:11px; color:#555; font-weight:900; margin-bottom:2px}
      #cwt-status{font-size:11px; color:#2b2b2b; white-space:pre-wrap}
      #cwt-footer{margin-top:10px; font-size:10px; color:#888}
      #cwt-footer a{color:#1f6feb; text-decoration:none}
      #cwt-footer a:hover{text-decoration:underline}
    `;
    const s = document.createElement('style');
    s.textContent = css;
    document.head.appendChild(s);
  };

  // -------------------- data --------------------
  let currentResults = [];
  let selected = [];

  const isSelected = (relId) => selected.some(x => Number(x.relId) === Number(relId));
  const addToSelected = (item) => {
    const relId = item?.osm_type === 'relation' ? Number(item.osm_id) : null;
    if (!relId || isSelected(relId)) return;
    selected.push({ ...item, relId });
    renderSelected();
  };
  const removeFromSelected = (relId) => {
    selected = selected.filter(x => Number(x.relId) !== Number(relId));
    renderSelected();
  };

  const makeNameSafe = (name) =>
    String(name || 'boundaries').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80);

  const fetchGeoForSelected = async () => {
    if (!selected.length) { setStatus(t('status_need_pick'), true); return []; }
    setStatus(t('status_fetch_geo'));
    const out = [];

    for (const it of selected) {
      const gj = await gmFetchJson(GEOJSON(it.relId));
      let feature = null;

      if (gj?.type === 'Feature') feature = gj;
      else if (gj?.type === 'FeatureCollection' && Array.isArray(gj.features) && gj.features.length) {
        feature = gj.features.find(f => f?.geometry?.type === 'Polygon' || f?.geometry?.type === 'MultiPolygon') || gj.features[0];
      } else if (gj?.type === 'Polygon' || gj?.type === 'MultiPolygon') {
        feature = { type: 'Feature', properties: {}, geometry: gj };
      }

      if (feature?.geometry) {
        feature.properties = feature.properties || {};
        feature.properties._relId = it.relId;
        feature.properties._display = it.display_name || '';
        out.push(feature);
      }
    }

    return out;
  };

  const buildCombinedGeoJSON_ALL = async () => {
    const features = await fetchGeoForSelected();
    if (!features.length) return null;
    return { type: 'FeatureCollection', features };
  };

  const combinedWkt_ALL = async () => {
    const gj = await buildCombinedGeoJSON_ALL();
    if (!gj) return null;
    const geoms = gj.features.map(f => f.geometry).filter(Boolean);
    const gc = { type: 'GeometryCollection', geometries: geoms };
    return geojsonToWkt(gc);
  };

  const combinedPoly = async () => {
    if (!selected.length) { setStatus(t('status_need_pick'), true); return null; }
    setStatus(t('status_fetch_poly'));
    const lines = [];
    for (const it of selected) {
      const txt = await gmFetchText(POLY(it.relId), 25000);
      lines.push(`# ---- ${it.display_name || ''} | relation ${it.relId} ----`);
      lines.push(txt.trim());
      lines.push('');
    }
    return lines.join('\n');
  };

  const actCopyThenOpenWKTMap = async (relId) => {
    try {
      setStatus(t('status_fetch_wkt'));
      const wkt = await gmFetchText(WKT_URL(relId), 25000);
      const ok = await copyToClipboard(wkt);
      if (ok) openNewTab(WKT_MAP_HOME());
    } catch (e) {
      setStatus(String(e.message || e), true);
    }
  };

  // ✅ Download WKT (ALL) as ONE file
  const actDownloadWKT_ALL = async () => {
    try {
      const wkt = await combinedWkt_ALL();
      if (!wkt) return;
      downloadTextFile(wkt, `combined_all.wkt`, 'text/plain;charset=utf-8');
    } catch (e) {
      setStatus(String(e.message || e), true);
    }
  };

  // ✅ per-item download (real save) without opening a text page
  const downloadItem = async (kind, it) => {
    const relId = Number(it.relId);
    const base = `${makeNameSafe(it.display_name)}_${relId}`;

    try {
      if (kind === 'geojson') {
        setStatus(t('status_fetch_geo'));
        return await downloadBinaryViaGM(GEOJSON(relId), `${base}.geojson`, 'application/geo+json');
      }
      if (kind === 'poly') {
        setStatus(t('status_fetch_poly'));
        return await downloadBinaryViaGM(POLY(relId), `${base}.poly`, 'text/plain;charset=utf-8');
      }
      if (kind === 'wkt') {
        setStatus(t('status_fetch_wkt'));
        return await downloadBinaryViaGM(WKT_URL(relId), `${base}.wkt`, 'text/plain;charset=utf-8');
      }
      if (kind === 'png') {
        // ✅ better status for PNG
        setStatus('Fetching PNG…');
        return await downloadBinaryViaGM(IMG_PNG(relId), `${base}.png`, 'image/png');
      }
    } catch (e) {
      setStatus(String(e.message || e), true);
    }
  };

  // -------------------- renderers --------------------
  const renderResults = () => {
    const list = document.getElementById('cwt-resultsList');
    if (!list) return;
    list.innerHTML = '';

    if (!currentResults.length) {
      list.appendChild(el('div', { class: 'cwt-item', html: `<strong>${t('status_no_results')}</strong>` }));
      return;
    }

    currentResults.forEach((r) => {
      const relId = (r.osm_type === 'relation') ? Number(r.osm_id) : null;
      const admin = r?.extratags?.admin_level ? `admin_level=${r.extratags.admin_level}` : '';

      const item = el('div', { class: 'cwt-item' }, [
        el('div', { class: 'cwt-itemHead' }, [
          el('div', { style: 'flex:1' }, [
            el('strong', { html: escapeHtml(r.display_name || '(no name)') }),
            el('div', { class: 'cwt-meta', html: `relation / ${escapeHtml(r.osm_id)} ${admin ? ' • ' + admin : ''}` })
          ]),
          el('span', { class: 'cwt-addBtn', text: isSelected(relId) ? '✓' : '+', onclick: () => addToSelected(r) })
        ])
      ]);

      list.appendChild(item);
    });
  };

  const renderSelected = () => {
    const list = document.getElementById('cwt-selectedList');
    if (!list) return;
    list.innerHTML = '';

    if (!selected.length) {
      list.appendChild(el('div', { class: 'cwt-item', html: `<strong style="color:#777">${t('status_need_pick')}</strong>` }));
      return;
    }

    const exportBox = el('div', { class: 'cwt-exportBox' }, [
      el('div', { style: 'font-weight:900; margin-bottom:8px', text: t('export_one') }),
      el('div', { class: 'cwt-exportRow' }, [
        el('span', { class: 'cwt-ico dl', text: '⬇️ ' + t('exp_geo_all'), onclick: async () => {
          try {
            const gj = await buildCombinedGeoJSON_ALL();
            if (!gj) return;
            downloadTextFile(JSON.stringify(gj, null, 2), `combined_all.geojson`, 'application/geo+json');
          } catch (e) { setStatus(String(e.message || e), true); }
        }}),

        el('span', { class: 'cwt-ico', text: '🧾 ' + t('exp_wkt_all'), onclick: async () => {
          try {
            const wkt = await combinedWkt_ALL();
            if (!wkt) return;
            await copyToClipboard(wkt);
          } catch (e) { setStatus(String(e.message || e), true); }
        }}),

        el('span', { class: 'cwt-ico dl', text: '⬇️ ' + t('exp_wkt_all_dl'), onclick: actDownloadWKT_ALL }),

        el('span', { class: 'cwt-ico', text: '🚀 ' + t('exp_wkt_open'), onclick: async () => {
          try {
            const wkt = await combinedWkt_ALL();
            if (!wkt) return;
            const ok = await copyToClipboard(wkt);
            if (ok) openNewTab(WKT_MAP_HOME());
          } catch (e) { setStatus(String(e.message || e), true); }
        }}),

        el('span', { class: 'cwt-ico dl', text: '⬇️ ' + t('exp_poly'), onclick: async () => {
          try {
            const txt = await combinedPoly();
            if (!txt) return;
            downloadTextFile(txt, `combined.poly`, 'text/plain;charset=utf-8');
          } catch (e) { setStatus(String(e.message || e), true); }
        }})
      ])
    ]);

    list.appendChild(exportBox);

    selected.forEach((it) => {
      const relId = Number(it.relId);
      const admin = it?.extratags?.admin_level ? `admin_level=${it.extratags.admin_level}` : '';

      const row = el('div', { class: 'cwt-item' });
      const head = el('div', { class: 'cwt-itemHead' }, [
        el('div', { style: 'flex:1' }, [
          el('strong', { html: escapeHtml(it.display_name || '(no name)') }),
          el('div', { class: 'cwt-meta', html: `relation / ${relId} ${admin ? ' • ' + admin : ''}` })
        ]),
        el('span', { class: 'cwt-xBtn', text: '✖', onclick: () => removeFromSelected(relId) })
      ]);

      const actions = el('div', { class: 'cwt-actionsRow' }, [
        el('span', { class: 'cwt-ico', text: '🚀', title: 'Copy WKT then open WKTMap', onclick: () => actCopyThenOpenWKTMap(relId) }),
        el('span', { class: 'cwt-ico dl', text: '⬇️ GeoJSON', onclick: () => downloadItem('geojson', it) }),
        el('span', { class: 'cwt-ico dl', text: '⬇️ POLY',   onclick: () => downloadItem('poly', it) }),
        el('span', { class: 'cwt-ico dl', text: '⬇️ WKT',    onclick: () => downloadItem('wkt', it) }),
        el('span', { class: 'cwt-ico dl', text: '🖼️ PNG',    onclick: () => downloadItem('png', it) }),
      ]);

      row.appendChild(head);
      row.appendChild(actions);
      list.appendChild(row);
    });

    setStatus(t('status_ready'));
  };

  // -------------------- searches --------------------
  const doSearch = async () => {
    const q = (document.getElementById('cwt-query')?.value || '').trim();
    if (!q) return;
    S.lastQuery = q; saveSettings();

    setStatus(t('status_searching'));
    currentResults = [];
    renderResults();

    try {
      currentResults = await nominatimSearch(q, S.level);
      renderResults();
      setStatus(currentResults.length ? t('status_ready') : t('status_no_results'));
    } catch (e) {
      setStatus(String(e.message || e), true);
    }
  };

  const doBatchSearch = async () => {
    const raw = (document.getElementById('cwt-batch')?.value || '').trim();
    if (!raw) return;
    S.lastBatch = raw; saveSettings();

    const lines = raw.split('\n').map(x => x.trim()).filter(Boolean);
    if (!lines.length) return;

    setStatus(t('status_searching'));
    const merged = [];

    for (const q of lines) {
      try {
        const res = await nominatimSearch(q, S.level);
        (res || []).slice(0, 6).forEach(r => merged.push(r));
      } catch {}
    }

    const seen = new Set();
    currentResults = merged.filter(r => r.osm_type === 'relation' && !seen.has(r.osm_id) && (seen.add(r.osm_id), true));
    renderResults();
    setStatus(currentResults.length ? t('status_ready') : t('status_no_results'));
  };

  // -------------------- panel init --------------------
  const initPanel = async () => {
    try {
      injectCss();

      const { tabLabel, tabPane } = await sdk.Sidebar.registerScriptTab();
      tabLabel.textContent = t('tab');

      const root = el('div', { id: 'cwt-root', dir: (S.lang === 'ar' ? 'rtl' : 'ltr') });

      root.appendChild(el('div', { id: 'cwt-title', text: t('title') }));
      root.appendChild(el('div', { id: 'cwt-sub', text: t('subtitle') }));

      root.appendChild(el('div', { id: 'cwt-row' }, [
        el('div', {}, [
          el('label', { text: t('lang') }),
          el('select', {
            id: 'cwt-lang',
            onchange: (e) => {
              S.lang = e.target.value === 'ar' ? 'ar' : 'en';
              saveSettings();
              tabPane.innerHTML = '';
              currentResults = [];
              selected = [];
              initPanel();
            }
          }, [
            el('option', { value: 'en', text: 'English' }),
            el('option', { value: 'ar', text: 'العربية' })
          ])
        ]),
        el('div', {}, [
          el('label', { text: t('level') }),
          el('select', {
            id: 'cwt-level',
            onchange: (e) => { S.level = e.target.value; saveSettings(); }
          }, [
            el('option', { value: 'country', text: t('level_country') }),
            el('option', { value: 'state', text: t('level_state') }),
            el('option', { value: 'city', text: t('level_city') })
          ])
        ])
      ]));

      root.appendChild(el('label', { text: t('query') }));
      root.appendChild(el('input', {
        id: 'cwt-query',
        type: 'text',
        value: S.lastQuery || '',
        placeholder: t('query_ph'),
        onkeydown: (e) => { if (e.key === 'Enter') doSearch(); }
      }));
      root.appendChild(el('button', { id: 'cwt-searchBtn', text: t('search'), onclick: doSearch }));

      root.appendChild(el('div', { id: 'cwt-blockTitle', text: t('batch') }));
      root.appendChild(el('textarea', { id: 'cwt-batch', placeholder: t('batch_ph') }));
      root.appendChild(el('button', { id: 'cwt-batchBtn', text: t('batch_btn'), onclick: doBatchSearch }));

      root.appendChild(el('div', { id: 'cwt-blockTitle', text: t('results') }));
      root.appendChild(el('div', { id: 'cwt-resultsList' }));

      root.appendChild(el('div', { id: 'cwt-blockTitle', text: t('selected') }));
      root.appendChild(el('div', { id: 'cwt-selectedList' }));

      root.appendChild(el('div', { id: 'cwt-statusWrap' }, [
        el('div', { id: 'cwt-statusTitle', text: t('status') }),
        el('div', { id: 'cwt-status', text: t('status_ready') })
      ]));

      // ✅ footer with site + auto version
      root.appendChild(el('div', {
        id: 'cwt-footer',
        html: `${t('footer')} — <a href="https://casperdevs.com/" target="_blank" rel="noopener noreferrer">casperdevs.com</a> — v${VERSION}`
      }));

      tabPane.appendChild(root);

      document.getElementById('cwt-lang').value = S.lang;
      document.getElementById('cwt-level').value = S.level;
      document.getElementById('cwt-batch').value = S.lastBatch || '';

      renderResults();
      renderSelected();
      setStatus(t('status_ready'));
    } catch (e) {
      console.error('WME Boundary Tools: initPanel failed', e);
    }
  };

  await initPanel();

})();