WME Smart Connect Fill

Copy primary/alternate address and speed from connected segments; optional auto-lock for new segments.

// ==UserScript==
// @name            WME Smart Connect Fill
// @namespace       smart-connect-fill/wme
// @version         2025.10.11.070
// @description     Copy primary/alternate address and speed from connected segments; optional auto-lock for new segments.
// @author          Ari
// @match           https://www.waze.com/editor*
// @match           https://www.waze.com/*/editor*
// @match           https://beta.waze.com/editor*
// @match           https://beta.waze.com/*/editor*
// @license         GPL-2.0
// @grant           GM_info
// @grant           GM.xmlHttpRequest
// @grant           GM_xmlhttpRequest
// @require         https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.js
// @connect         gist.githubusercontent.com
// ==/UserScript==

/* global bootstrap */

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

  // ---------- logging ----------
  const LOG = 'SCF:';
  const dbg  = (...a) => console.debug(LOG, ...a);
  const warn = (...a) => console.warn(LOG,  ...a);
  const err  = (...a) => console.error(LOG, ...a);

  let sdk;

  // ---------- constants ----------
  const I18N_URL = 'https://gist.githubusercontent.com/ariwazer694/8c7b1950e48fee54d6d5291f48544fce/raw/6766bf25fc90d4c0a16237dc8cdb3ebddcbc7260/wme-smart-connect-fill-lang.json';

  // Fallback English
  const I_EN = Object.freeze({
    tab_title: 'Smart Connect Fill',
    intro: 'Fills addresses from connected roads; optional speed + lock.',
    enable: 'Enable on new / just-connected segments',
    copy_street: 'Copy Street name',
    copy_city: 'Copy City',
    copy_state_country: 'Copy State + Country',
    copy_alternate: 'Copy Alternate names',
    copy_speed: 'Copy Speed limit(s)',
    also_nameless_existing: 'Also fill when segment has no street name (existing)',
    auto_lock: 'Auto-lock new segments',
    help: 'Fills only empty/unset fields.',
    ready: 'Smart Connect Fill ready',
    reset_cache: 'Reset processed cache',
    smart_connect_fill: 'SMART CONNECT FILL'
  });

  // ---------- settings/state ----------
  const STORAGE_KEY = 'wme_scf_settings';
  const DEFAULTS = Object.freeze({
    enableSCF: false,

    // copy policies
    inheritStreet: true,
    inheritCity: true,
    inheritStateCountry: true,
    inheritAlternate: true,
    onNamelessExisting: false,
    inheritSpeed: true,

    // lock
    enableAutoLock: false,
    autoLockLevel: 'my' // 'my' | '1'..'6'
  });
  const SETTINGS_KEYS = Object.keys(DEFAULTS);
  let S = { ...DEFAULTS };

  // Active i18n dict + all dictionaries
  let I = { ...I_EN };
  let I_ALL = null;

  // Per-session guards
  const processedSegments = new Set();
  const lockCandidates = new Set();
  const lockProcessed  = new Set();

  // ---------- small utils ----------
  const $  = (id) => document.getElementById(id);
  const on = (el, evt, cb) => el && el.addEventListener(evt, cb);
  const debounce = (fn, ms = 120) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };

  const loadSettings = () => {
    try { S = { ...DEFAULTS, ...(JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')) }; }
    catch { S = { ...DEFAULTS }; }
    dbg('settings loaded', S);
  };
  const saveSettings = () => {
    const out = {}; SETTINGS_KEYS.forEach(k => out[k] = S[k]);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(out));
    dbg('settings saved', out);
  };

  const setChecked = (id, v) => {
    const e = $(id); if (!e) return;
    e.checked = !!v;
    if (v) e.setAttribute('checked', ''); else e.removeAttribute('checked');
  };
  const setValue = (id, v) => { const e = $(id); if (e) e.value = String(v ?? ''); };

  const isSelected = (id) => {
    const sel = sdk.Editing.getSelection();
    return (sel?.objectType === 'segment' && Array.isArray(sel.ids)) ? sel.ids.includes(id) : false;
  };

  const getEditorLocale = () => {
    try {
      if (sdk?.Environment?.getLocale) return String(sdk.Environment.getLocale());
      const u = (sdk?.User?.getUser && sdk.User.getUser()) || sdk?.User;
      if (u?.locale) return String(u.locale);
    } catch {}
    return String(document.documentElement?.lang || navigator.language || 'en');
  };

  const isNorwayPath = () => {
    try { return (location.pathname.split('/')[1] || '').toLowerCase() === 'no'; }
    catch { return false; }
  };

  // Map browser/editor locale
  const chooseLang = (locRaw) => {
    const loc = (locRaw || 'en').toLowerCase().replace('_', '-');
    const [lang, region] = loc.split('-');
    if (lang === 'nb' || lang === 'nn' || lang === 'no') {
      if (!region || region === 'no' || isNorwayPath()) return 'no';
    }
    if (isNorwayPath()) return 'no';
    if (lang === 'de') return 'de';
    if (lang === 'da') return 'da';
    if (lang === 'sv') return 'sv';
    if (lang === 'es') return 'es';
    if (lang === 'en') return 'en';
    return 'en';
  };

  const mergeInto = (base, add) => {
    const out = { ...base };
    if (add && typeof add === 'object') for (const k of Object.keys(add)) if (add[k] != null) out[k] = add[k];
    return out;
  };

  // Fetch JSON via GMXHR
  const gmFetchJson = (url, timeoutMs = 8000) => new Promise((resolve, reject) => {
    const xhr = (GM && GM.xmlHttpRequest) ? GM.xmlHttpRequest : (typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null);
    if (!xhr) return reject(new Error('GM(XHR) unavailable'));
    let done = false;
    const timer = setTimeout(() => { if (!done) { done = true; reject(new Error('timeout')); } }, timeoutMs);
    try {
      xhr({
        method: 'GET', url, headers: { Accept: 'application/json' },
        onload: (res) => {
          if (done) return; done = true; clearTimeout(timer);
          if (res.status >= 200 && res.status < 300) {
            try { resolve(JSON.parse(res.responseText)); }
            catch { reject(new Error('JSON parse error')); }
          } else reject(new Error('HTTP ' + res.status));
        },
        onerror: () => { if (!done) { done = true; clearTimeout(timer); reject(new Error('network error')); } },
        ontimeout: () => { if (!done) { done = true; clearTimeout(timer); reject(new Error('timeout')); } }
      });
    } catch (e) { if (!done) { done = true; clearTimeout(timer); reject(e); } }
  });

  // Load i18n and pick active dict by auto-detected language
  const loadI18n = async () => {
    try {
      I_ALL = await gmFetchJson(I18N_URL, 8000);
      if (!I_ALL || typeof I_ALL !== 'object') throw new Error('Invalid JSON root');
      const auto = chooseLang(getEditorLocale());
      const dict = I_ALL[auto] || I_ALL['en'] || {};
      I = mergeInto(I_EN, dict);
      dbg('i18n loaded', { auto, keys: Object.keys(dict).length });
    } catch (e) {
      warn('i18n load failed; using built-in English', e);
      I_ALL = { en: I_EN };
      I = { ...I_EN };
    }
  };

  // ---------- styles ----------
  const injectCss = () => {
    const css = `
      #sidepanel-scf{font-size:13px}
      #sidepanel-scf .controls-container{padding:0 0 4px 0}
      #sidepanel-scf .row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
      #sidepanel-scf .scf-group-label{font-size:11px;width:100%;font-family:Poppins,sans-serif;text-transform:uppercase;font-weight:700;color:#354148;margin:8px 0 6px}
      #sidepanel-scf label{white-space:normal}
      #sidepanel-scf .muted{font-size:10px;color:#999}
      #sidepanel-scf .scf-details{margin-left:.25rem}
      #scfResetProcessedBtn{margin-top:6px;padding:4px 8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;cursor:pointer;font-size:12px}
      #scfHelpNote{font-size:10px;color:#777;margin:6px 0 0 0}
      #scfIntro{font-size:12px;color:#555;margin:6px 0 8px 0;line-height:1.35}
      #scfAutoLockSelect{font-size:12px;padding:2px 4px}
    `;
    const el = document.createElement('style'); el.textContent = css; document.head.appendChild(el);
  };

  // ---------- UI ----------
  const label = (text) => { const l = document.createElement('label'); l.className = 'scf-group-label'; l.textContent = text; return l; };
  const checkbox = (id, setting, text) => {
    const wrap = document.createElement('div'); wrap.className = 'controls-container';
    wrap.innerHTML = `<input type="checkbox" id="${id}" class="scfSettingsControl" data-setting="${setting}"><label for="${id}">${text}</label>`;
    return wrap;
  };
  const autoLockRow = () => {
    const row = document.createElement('div'); row.className = 'controls-container';
    row.innerHTML = `
      <input type="checkbox" id="scfEnableAutoLockCheckBox" class="scfSettingsControl" data-setting="enableAutoLock">
      <label for="scfEnableAutoLockCheckBox">${I.auto_lock}</label>
      <select id="scfAutoLockSelect">
        <option value="my">My level</option>
        <option value="1">Level 1</option><option value="2">Level 2</option>
        <option value="3">Level 3</option><option value="4">Level 4</option>
        <option value="5">Level 5</option><option value="6">Level 6</option>
      </select>`;
    return row;
  };

  const syncVisibility = () => {
    const details = document.querySelector('#sidepanel-scf .scf-details');
    if (details) details.style.display = S.enableSCF ? '' : 'none';
  };

  const bindUIFromSettings = () => {
    setChecked('scfEnableCheckBox', S.enableSCF);
    setChecked('scfInheritStreetCheckBox', S.inheritStreet);
    setChecked('scfInheritCityCheckBox', S.inheritCity);
    setChecked('scfInheritStateCountryCheckBox', S.inheritStateCountry);
    setChecked('scfInheritAlternateCheckBox', S.inheritAlternate);
    setChecked('scfOnNamelessExistingCheckBox', S.onNamelessExisting);
    setChecked('scfInheritSpeedCheckBox', S.inheritSpeed);
    setChecked('scfEnableAutoLockCheckBox', S.enableAutoLock);
    setValue('scfAutoLockSelect', S.autoLockLevel);
    syncVisibility();
  };

  const bindDeferred = () => requestAnimationFrame(() => requestAnimationFrame(bindUIFromSettings));

  const initPanel = async () => {
    const { tabLabel, tabPane } = await sdk.Sidebar.registerScriptTab();
    tabLabel.textContent = I.tab_title;

    const panel = document.createElement('div'); panel.id = 'sidepanel-scf';
    panel.appendChild(label(I.smart_connect_fill));

    const intro = document.createElement('div'); intro.id = 'scfIntro'; intro.textContent = I.intro; panel.appendChild(intro);

    // Master enable
    panel.appendChild(checkbox('scfEnableCheckBox', 'enableSCF', I.enable));

    const details = document.createElement('div'); details.className = 'scf-details';
    details.appendChild(checkbox('scfInheritStreetCheckBox', 'inheritStreet', I.copy_street));
    details.appendChild(checkbox('scfInheritCityCheckBox', 'inheritCity', I.copy_city));
    details.appendChild(checkbox('scfInheritStateCountryCheckBox', 'inheritStateCountry', I.copy_state_country));
    details.appendChild(checkbox('scfInheritAlternateCheckBox', 'inheritAlternate', I.copy_alternate));
    details.appendChild(checkbox('scfOnNamelessExistingCheckBox', 'onNamelessExisting', I.also_nameless_existing));
    details.appendChild(checkbox('scfInheritSpeedCheckBox', 'inheritSpeed', I.copy_speed));

    // Auto-lock toggle + target level
    details.appendChild(autoLockRow());

    const help = document.createElement('div'); help.id = 'scfHelpNote'; help.textContent = I.help; details.appendChild(help);
    panel.appendChild(details);

    const footer = document.createElement('div'); footer.className = 'muted'; footer.textContent = I.ready; panel.appendChild(footer);

    const resetBtn = document.createElement('button'); resetBtn.id = 'scfResetProcessedBtn'; resetBtn.textContent = I.reset_cache;
    on(resetBtn, 'click', () => { processedSegments.clear(); lockCandidates.clear(); lockProcessed.clear(); dbg('caches cleared'); });
    panel.appendChild(resetBtn);

    tabPane.appendChild(panel);

    on(panel, 'change', (evt) => {
      const t = evt.target; if (!t) return;
      if (t.id === 'scfAutoLockSelect') { S.autoLockLevel = String(t.value); saveSettings(); dbg('autoLock level', S.autoLockLevel); return; }
      if (!t.classList.contains('scfSettingsControl')) return;
      const key = t.dataset.setting; if (!SETTINGS_KEYS.includes(key)) return;
      S[key] = !!t.checked; saveSettings(); if (key === 'enableSCF') syncVisibility();
    });

    // Keep checkbox states even if sidebar rerenders
    const mo = new MutationObserver(() => bindDeferred());
    mo.observe(panel, { childList: true, subtree: true });

    bindDeferred();
    dbg('panel ready');
  };

  // ---------- data helpers ----------
  const getSelectionSegmentIds = () => {
    const sel = sdk.Editing.getSelection();
    return (sel?.objectType === 'segment' && Array.isArray(sel.ids)) ? sel.ids : [];
  };

  const getNeighbors = (id) => {
    const a = sdk.DataModel.Segments.getConnectedSegments({ segmentId: id, reverseDirection: false }) || [];
    const b = sdk.DataModel.Segments.getConnectedSegments({ segmentId: id, reverseDirection: true }) || [];
    const out = [...a, ...b].map(s => s.id);
    dbg('neighbors', id, out);
    return out;
  };

  const isLikelyNew = (id, seg) => {
    if (typeof id === 'number' && id < 0) return true;
    if (typeof id === 'string' && /^tmp|^neg/i.test(id)) return true;
    if (seg?.isNew || seg?.isCreated) return true;
    const addr = sdk.DataModel.Segments.getAddress({ segmentId: id });
    return (!addr || addr.isEmpty === true);
  };

  const isUnsavedNew = (id, seg) =>
    (typeof id === 'number' && id < 0) ||
    (typeof id === 'string' && /^tmp|^neg/i.test(id)) ||
    !!(seg?.isNew || seg?.isCreated);

  const isStreetUnset = (addr) => {
    try { if (!addr || !addr.street || addr.street.isEmpty) return true; const n = addr.street.name; return n == null || n === ''; }
    catch { return true; }
  };

  const firstNeighborWithAddress = (id) => {
    const seen = new Set([id]); const q = [id];
    const hasAddr = (sid) => { try { const a = sdk.DataModel.Segments.getAddress({ segmentId: sid }); return a && !a.isEmpty; } catch { return false; } };
    while (q.length) {
      const cur = q.shift();
      const ns = getNeighbors(cur);
      const donorId = ns.find(hasAddr);
      if (donorId) {
        const addr = sdk.DataModel.Segments.getAddress({ segmentId: donorId });
        dbg('donor addr', donorId, addr);
        return { donorId, addr };
      }
      ns.forEach(n => { if (!seen.has(n)) { seen.add(n); q.push(n); } });
    }
    dbg('no donor addr');
    return null;
  };

  const firstNeighborWithSpeed = (id) => {
    const ns = getNeighbors(id);
    for (let i = 0; i < ns.length; i++) {
      const seg = sdk.DataModel.Segments.getById({ segmentId: ns[i] });
      if (!seg) continue;
      if (seg.fwdSpeedLimit != null || seg.revSpeedLimit != null) {
        dbg('donor speed', ns[i], { fwd: seg.fwdSpeedLimit, rev: seg.revSpeedLimit });
        return seg;
      }
    }
    dbg('no donor speed');
    return null;
  };

  const getOrCreateStreet = (streetName, cityId) => {
    try {
      return sdk.DataModel.Streets.getStreet({ streetName, cityId }) ||
             sdk.DataModel.Streets.addStreet({ streetName, cityId });
    } catch (e) { err('street get/create failed', { streetName, cityId, e }); throw e; }
  };

  // ---------- alternates ----------
  const normalizeAlt = (alt) => {
    if (typeof alt === 'number') return { streetId: alt };
    if (typeof alt === 'string' && /^\d+$/.test(alt)) return { streetId: +alt };
    const streetId   = alt?.streetId ?? alt?.id ?? alt?.street?.id;
    const streetName = alt?.street?.name ?? alt?.streetName ?? '';
    const cityName   = alt?.city?.name   ?? alt?.cityName   ?? '';
    const stateId    = alt?.state?.id    ?? alt?.stateId    ?? undefined;
    const countryId  = alt?.country?.id  ?? alt?.countryId  ?? undefined;
    return { streetId, streetName, cityName, stateId, countryId };
  };

  const resolveStreetId = (n) => {
    if (n.streetId != null) return n.streetId;
    const cityProps = { cityName: n.cityName || '', stateId: n.stateId, countryId: n.countryId };
    let cityId = sdk.DataModel.Cities.getCity(cityProps)?.id;
    if (cityId == null) { cityId = sdk.DataModel.Cities.addCity(cityProps).id; dbg('alt city created', cityProps); }
    return getOrCreateStreet(n.streetName || '', cityId).id;
  };

  const readDonorAlternates = (donorId, donorAddr) => {
    try {
      if (typeof sdk.DataModel?.Segments?.getAlternateStreets === 'function') {
        const alts = sdk.DataModel.Segments.getAlternateStreets({ segmentId: donorId }) || [];
        dbg('donor alternates via getAlternateStreets', donorId, alts);
        return alts;
      }
    } catch (e) { warn('getAlternateStreets failed', e); }
    try {
      const seg = sdk.DataModel.Segments.getById({ segmentId: donorId }) || {};
      if (Array.isArray(seg.alternateStreetIds)) return seg.alternateStreetIds;
      if (Array.isArray(seg.alternateStreets))   return seg.alternateStreets;
    } catch {}
    try {
      const viaAddr = donorAddr?.alternateStreets || donorAddr?.alternates || donorAddr?.alternateNames || [];
      dbg('donor alternates via address fallback', donorId, viaAddr);
      return viaAddr;
    } catch {}
    dbg('no donor alternates detected', donorId);
    return [];
  };

  const readExistingAlternateIds = (segId) => {
    try {
      if (typeof sdk.DataModel?.Segments?.getAlternateStreets === 'function') {
        const arr = sdk.DataModel.Segments.getAlternateStreets({ segmentId: segId }) || [];
        return arr.map(normalizeAlt).map(resolveStreetId);
      }
      const seg = sdk.DataModel.Segments.getById({ segmentId: segId }) || {};
      if (Array.isArray(seg.alternateStreetIds)) return seg.alternateStreetIds.slice();
      if (Array.isArray(seg.alternateStreets))  return seg.alternateStreets.map(normalizeAlt).map(resolveStreetId);
    } catch {}
    return [];
  };

  const applyAlternateIds = (segId, altIds) => {
    if (!altIds?.length) return false;
    try {
      sdk.DataModel.Segments.updateAddress({ segmentId: segId, alternateStreetIds: altIds });
      dbg('alternates applied via updateAddress', { segId, altIds });
      return true;
    } catch (e1) { warn('updateAddress(alternateStreetIds) failed', e1); }
    try {
      sdk.DataModel.Segments.updateSegment({ segmentId: segId, alternateStreetIds: altIds });
      dbg('alternates applied via updateSegment', { segId, altIds });
      return true;
    } catch (e2) { warn('updateSegment(alternateStreetIds) failed', e2); }
    try {
      if (typeof sdk.DataModel?.Segments?.setAlternateStreets === 'function') {
        sdk.DataModel.Segments.setAlternateStreets({ segmentId: segId, alternateStreetIds: altIds });
        dbg('alternates applied via setAlternateStreets', { segId, altIds });
        return true;
      }
    } catch (e3) { warn('setAlternateStreets failed', e3); }
    try {
      if (typeof sdk.DataModel?.Segments?.addAlternateStreet === 'function') {
        let any = false;
        altIds.forEach((sid) => {
          try { sdk.DataModel.Segments.addAlternateStreet({ segmentId: segId, streetId: sid }); any = true; }
          catch (e4) { warn('addAlternateStreet failed for ' + sid, e4); }
        });
        if (any) { dbg('alternates applied via addAlternateStreet loop', { segId, altIds }); return true; }
      }
    } catch (e5) { warn('addAlternateStreet loop failed', e5); }
    err('failed to set alternateStreetIds', { segId, altIds });
    return false;
  };

  // ---------- lock ----------
  const getUserRank0 = () => {
    try {
      const u = (sdk.User?.getUser && sdk.User.getUser()) || sdk.User || {};
      const c = [u.rank,u.userRank,u.editorRank,u.level,u.userLevel,u.editorLevel,u.attributes?.rank,u.attributes?.level]
        .filter(n => typeof n === 'number');
      if (c.length) { const m = Math.max(...c); return (m >= 1 && m <= 6) ? (m - 1) : Math.max(0, Math.min(5, m)); }
    } catch (e) { warn('user rank resolve failed', e); }
    warn('user rank not detected; defaulting to 0'); return 0;
  };

  const desiredLockRank0 = () => {
    const user0 = getUserRank0();
    const sel = String(S.autoLockLevel || 'my');
    const desired0 = (sel === 'my') ? user0 : Math.max(0, Math.min(5, (parseInt(sel, 10) || (user0 + 1)) - 1));
    dbg('desired lock', { selection: sel, userRank0: user0, desired0 });
    return desired0;
  };

  const setLockForNew = (id) => {
    if (!S.enableAutoLock) return false;
    const seg = sdk.DataModel.Segments.getById({ segmentId: id }); if (!seg) return false;
    if (!lockCandidates.has(id)) return dbg('lock skip: not a candidate', { id }), false;
    if (!isSelected(id))         return dbg('lock skip: not selected', { id }), false;
    if (!isUnsavedNew(id, seg))  return dbg('lock skip: not unsaved-new', { id }), false;
    const addr = sdk.DataModel.Segments.getAddress({ segmentId: id });
    if (!isStreetUnset(addr))    return dbg('lock skip: street set', { id }), false;

    const desired = desiredLockRank0();
    const curLock = (typeof seg.lockRank === 'number') ? seg.lockRank : null;
    const inhRank = (typeof seg.rank === 'number')     ? seg.rank     : null;
    const effective = (curLock != null) ? curLock : (inhRank != null ? inhRank : -1);
    if (effective >= 0 && desired < effective) return dbg('lock skip: would lower', { id, effective, desired }), false;
    if (curLock === desired) return dbg('lock unchanged', { id, desired }), false;

    try { sdk.DataModel.Segments.updateSegment({ segmentId: id, lockRank: desired }); dbg('locked', { id, desired }); return true; }
    catch (e) { err('lock set failed', { id, desired, e }); return false; }
  };

  // ---------- core: address (primary + alternates) ----------
  const inheritAddress = (id) => {
    const targetAddr = sdk.DataModel.Segments.getAddress({ segmentId: id });
    const donorBundle = firstNeighborWithAddress(id); if (!donorBundle) return false;
    const { donorId, addr: donor } = donorBundle;

    const wantS   = S.inheritStreet;
    const wantC   = S.inheritCity || S.inheritStateCountry;
    const wantSC  = S.inheritStateCountry;
    const wantAlt = !!S.inheritAlternate;

    const curStreet = targetAddr?.street?.name ?? '';
    const curCityId = targetAddr?.city?.id ?? null;

    const setStreet = wantS && !curStreet;
    const setCity   = (wantC || wantSC) && (curCityId == null);

    const cityProps = {
      cityName: (setCity && donor.city && !donor.city.isEmpty && S.inheritCity) ? donor.city.name : (targetAddr.city?.name ?? ''),
      stateId:  (setCity && donor.state) ? donor.state.id : (targetAddr.state?.id ?? undefined),
      countryId:(setCity && donor.country) ? donor.country.id : (targetAddr.country?.id ?? undefined)
    };
    if (wantSC && !S.inheritCity) cityProps.cityName = '';

    let cityId = sdk.DataModel.Cities.getCity(cityProps)?.id;
    if (cityId == null) { cityId = sdk.DataModel.Cities.addCity(cityProps).id; dbg('city created', cityProps); }

    const donorStreetName = donor?.street?.name ?? '';
    const streetName      = setStreet ? donorStreetName : (targetAddr.street?.name ?? '');
    const primaryStreetId = getOrCreateStreet(streetName, cityId).id;

    let altIds = [];
    if (wantAlt) {
      const donorAlts = readDonorAlternates(donorId, donor) || [];
      const normalized = donorAlts.map(normalizeAlt)
        .filter(a => (a.streetId != null) || ((a.streetName ?? '').trim().length > 0));
      altIds = normalized.map(resolveStreetId);
      const existingAltIds = readExistingAlternateIds(id);
      altIds = altIds
        .filter((sid, i, arr) => arr.indexOf(sid) === i)
        .filter(sid => sid !== primaryStreetId)
        .filter(sid => !existingAltIds.includes(sid));
    }

    let changed = false;
    if (setStreet || setCity) {
      try { sdk.DataModel.Segments.updateAddress({ segmentId: id, primaryStreetId }); dbg('primary address updated', { id, primaryStreetId }); changed = true; }
      catch (e) { err('primary address update failed', { id, e }); }
    }
    if (wantAlt && altIds.length) { if (applyAlternateIds(id, altIds)) changed = true; }

    return changed;
  };

  // ---------- core: speed ----------
  const inheritSpeed = (id) => {
    if (!S.inheritSpeed) return false;
    const target = sdk.DataModel.Segments.getById({ segmentId: id }); if (!target) return false;
    const donor  = firstNeighborWithSpeed(id); if (!donor) return false;

    const tF = target.fwdSpeedLimit, tR = target.revSpeedLimit;
    const dF = donor.fwdSpeedLimit,  dR = donor.revSpeedLimit;

    const upd = { segmentId: id }; let changed = false;
    if (dF != null && (tF == null)) { upd.fwdSpeedLimit = dF; changed = true; }
    if (dR != null && (tR == null)) { upd.revSpeedLimit = dR; changed = true; }
    if (!changed) return false;

    try { sdk.DataModel.Segments.updateSegment(upd); dbg('speed updated', { id, fwd: upd.fwdSpeedLimit, rev: upd.revSpeedLimit }); return true; }
    catch (e) { err('speed update failed', { id, upd, e }); return false; }
  };

  // ---------- driver ----------
  const run = () => {
    if (!S.enableSCF) return dbg('SCF disabled');
    const ids = getSelectionSegmentIds(); if (!ids.length) return dbg('no selection');

    ids.forEach((id) => {
      const seg = sdk.DataModel.Segments.getById({ segmentId: id }); if (!seg) return;

      const newLikely   = isLikelyNew(id, seg);
      const selectedNow = isSelected(id);
      const trulyNew    = isUnsavedNew(id, seg);
      const candidate   = selectedNow && lockCandidates.has(id);

      const addr = sdk.DataModel.Segments.getAddress({ segmentId: id });
      const streetMissing    = isStreetUnset(addr);
      const namelessExisting = streetMissing && !newLikely;

      dbg('candidate', { id, newLikely, trulyNew, selectedNow, candidate, streetMissing, namelessExisting });

      if (candidate && trulyNew && S.enableAutoLock && !lockProcessed.has(id)) {
        try { if (setLockForNew(id)) lockProcessed.add(id); } catch (e) { err('auto-lock error', e); }
      }
      if (candidate && trulyNew) {
        try { inheritSpeed(id); } catch (e) { err('speed error', e); }
      }

      if (processedSegments.has(id)) return dbg('skip processed', id);

      const shouldAddress = (newLikely && streetMissing) || (S.onNamelessExisting && namelessExisting);
      if (!shouldAddress) return dbg('policy skip', { id });

      if (inheritAddress(id)) { processedSegments.add(id); dbg('processed add', id); }
      else { dbg('address unchanged', id); }
    });
  };

  const runDebounced = debounce(run, 120);

  const waitForEditPanel = (tries = 40) => new Promise(res => {
    const tick = (n) => {
      const el = document.getElementById('edit-panel');
      if (el || n <= 0) return res(el || null);
      setTimeout(() => tick(n - 1), 250);
    };
    tick(tries);
  });

  const init = async () => {
    dbg('init start');
    injectCss();
    loadSettings();
    await loadI18n();
    await initPanel();

    on(window, 'beforeunload', saveSettings);

    try {
      sdk.Events.trackDataModelEvents({ dataModelName: 'segments' });
      sdk.Events.on({ eventName: 'wme-data-model-objects-changed', eventHandler: () => { try { runDebounced(); } catch (e) { err('data change', e); } } });
      dbg('events:data wired');
    } catch (e) { err('track events fail', e); }

    try {
      sdk.Events.on({
        eventName: 'wme-selection-changed',
        eventHandler: () => {
          try {
            const sel = sdk.Editing.getSelection();
            const ids = (sel?.objectType === 'segment' && Array.isArray(sel.ids)) ? sel.ids : [];
            ids.forEach(id => {
              const seg  = sdk.DataModel.Segments.getById({ segmentId: id });
              const addr = sdk.DataModel.Segments.getAddress({ segmentId: id });
              if (isUnsavedNew(id, seg) && isStreetUnset(addr)) { lockCandidates.add(id); dbg('lock candidate add', id); }
            });
            runDebounced();
          } catch (e) { err('on selection change', e); }
        }
      });
      dbg('events:selection wired');
    } catch (e) { err('selection events fail', e); }

    const editPanel = await waitForEditPanel();
    dbg('edit-panel?', !!editPanel);
    if (editPanel) {
      const mo = new MutationObserver((muts) => {
        for (const m of muts) {
          for (let i = 0; i < m.addedNodes.length; i++) {
            const n = m.addedNodes[i];
            if (n.nodeType !== 1) continue;
            if (n.querySelector?.('.road-type-control') || n.querySelector?.('wz-chip-select.road-type-chip-select')) {
              try { runDebounced(); } catch (e) { err('mutation', e); }
            }
          }
        }
      });
      mo.observe(editPanel, { childList: true, subtree: true });
      dbg('mutation wired');
    } else {
      warn('no edit-panel; mutation skip');
    }

    dbg('init done');
  };

  try {
    sdk = await bootstrap();
    if (!sdk) throw new Error('bootstrap returned falsy sdk');
    dbg('sdk', { hasSidebar: !!sdk.Sidebar, hasEvents: !!sdk.Events, hasSegments: !!sdk.DataModel?.Segments, hasEditing: !!sdk.Editing });
    await init();
  } catch (e) {
    err('bootstrap/init fail', e);
  }
})();