MediaWiki:Gadget-LabelScan.js: Unterschied zwischen den Versionen

Keine Bearbeitungszusammenfassung
Markierung: Manuelle Zurücksetzung
Keine Bearbeitungszusammenfassung
Zeile 3: Zeile 3:
   'use strict';
   'use strict';


   // ------------------------------------------------------------
   // -------- Config --------
  // Konfiguration
  // ------------------------------------------------------------
   const CFG = {
   const CFG = {
    // Wo liegt die JSON mit Index (Titel, Thumb, EMBEDDING)?
     indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
     indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
                 'MediaWiki:Gadget-LabelScan-index.json',
                 'MediaWiki:Gadget-LabelScan-index.json',
     // Top-N Treffer anzeigen:
     topKShow: 8,            // so viele Treffer anzeigen
     topK: 8,
     topKClip: 24,           // so viele Kandidaten vor CLIP (per pHash oder einfach die ersten)
     // CLIP-Model:
     maxSide: 1024,          // Downscale lange Bildkante vorm CLIP
     transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0',
     transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js',
     modelId: 'Xenova/clip-vit-base-patch32', // robust & kompakt (quantized)
     modelId: 'Xenova/clip-vit-base-patch32',
    // Max-Seitenkante beim Downscaling (Speed/Qualität):
    maxSide: 1024,
    // Logging:
     debug: true
     debug: true
   };
   };


   function log(...args) { if (CFG.debug) console.log('[LabelScan]', ...args); }
   // -------- Utils --------
   function warn(...args){ if (CFG.debug) console.warn('[LabelScan]', ...args); }
  const $ = id => document.getElementById(id);
   function err(...args) { console.error('[LabelScan]', ...args); }
  const log = (...a) => { if (CFG.debug) console.log('[LabelScan]', ...a); };
   const warn = (...a) => { if (CFG.debug) console.warn('[LabelScan]', ...a); };
   const err = (...a) => console.error('[LabelScan]', ...a);


  // ------------------------------------------------------------
   function setStatus(t){ const el=$('ados-scan-status'); if(el) el.textContent=t||''; }
  // UI Helpers
   function setProgress(p){
  // ------------------------------------------------------------
     const bar=$('ados-scan-progress'); if(!bar) return;
  function qs(id) { return document.getElementById(id); }
     if(p==null){ bar.hidden=true; bar.value=0; } else { bar.hidden=false; bar.value=Math.max(0,Math.min(1,p)); }
   function setStatus(txt) { const el = qs('ados-scan-status'); if (el) el.textContent = txt || ''; }
   function setProgress(p) {
     const bar = qs('ados-scan-progress'); if (!bar) return;
     if (p == null) { bar.hidden = true; bar.value = 0; }
    else { bar.hidden = false; bar.value = Math.max(0, Math.min(1, p)); }
   }
   }
   function showPreview(file) {
   function showPreview(file){
     const url = URL.createObjectURL(file);
     const url=URL.createObjectURL(file);
     const prev = qs('ados-scan-preview');
     const prev=$('ados-scan-preview');
     if (prev) {
     if(prev){
       prev.innerHTML = '<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;" src="'+url+'">';
       prev.innerHTML='<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;">';
       prev.setAttribute('aria-hidden','false');
       prev.querySelector('img').src=url;
     }
     }
   }
   }
  function esc(s){ return mw.html.escape(String(s||'')); }


   // ------------------------------------------------------------
   // -------- Index laden --------
  // Index laden (JSON: Titel, Thumb, embed(Base64 Float32))
   let INDEX=[], INDEX_EMB=[];
  // ------------------------------------------------------------
   async function loadIndex(){
   let INDEX = [];
     if(INDEX.length) return INDEX;
  let INDEX_EMB = []; // Array<Float32Array>
     setStatus('Index laden …'); setProgress(0.03);
 
     const raw = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
   async function loadIndex() {
     const res = await fetch(raw, { cache:'reload' });
     if (INDEX.length) return INDEX;
     if(!res.ok) throw new Error('Index HTTP '+res.status);
     setStatus('Index laden …');
     const data = await res.json();
    setProgress(0.03);
     if(!Array.isArray(data)) throw new Error('Index ist kein Array');
 
     INDEX = data.filter(x => x && x.title && x.thumb);
    // Roh-URL bauen
     INDEX_EMB = INDEX.map((it,i)=>{
     const rawURL = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
       if(typeof it.embed === 'string' && it.embed){
     const res = await fetch(rawURL, { cache:'reload' });
         try { return base64ToFloat32(it.embed); } catch(e){ warn('Embed decode', i, it.title, e); }
     if (!res.ok) throw new Error('Index nicht ladbar: ' + res.status);
     const json = await res.json();
 
     if (!Array.isArray(json)) throw new Error('Index ist keine Array-JSON');
     INDEX = json;
 
    // Embeddings dekodieren (falls vorhanden)
     INDEX_EMB = INDEX.map((it, i) => {
       if (typeof it.embed === 'string' && it.embed.length) {
         try { return base64ToFloat32(it.embed); }
        catch(e){ warn('Embed-Decode-Fehler bei Index', i, it.title, e); return null; }
       }
       }
       return null;
       return null;
     });
     });
     log('Index geladen:', INDEX.length, 'Einträge');
     log('Index geladen:', INDEX.length, 'Einträge');
     setProgress(0.06);
     setProgress(0.06);
Zeile 78: Zeile 58:
   }
   }


  // Base64 -> Float32Array
   function base64ToFloat32(b64){
   function base64ToFloat32(b64) {
     const bin=atob(b64), buf=new ArrayBuffer(bin.length), u8=new Uint8Array(buf);
     const bin = atob(b64);
     for(let i=0;i<bin.length;i++) u8[i]=bin.charCodeAt(i);
    const len = bin.length;
    const buf = new ArrayBuffer(len);
    const view = new Uint8Array(buf);
     for (let i=0;i<len;i++) view[i] = bin.charCodeAt(i);
     return new Float32Array(buf);
     return new Float32Array(buf);
   }
   }


// ------------------------------------------------------------
  // -------- pHash Helfer (optional) --------
// CLIP: Feature-Extraction (nur Bild, ohne Tokenizer)
  function hexToBigInt(h){ try { return BigInt('0x'+String(h).trim()); } catch{ return null; } }
// ------------------------------------------------------------
  function ham64(aHex,bHex){
let _clipModulePromise = null;
    const a=hexToBigInt(aHex), b=hexToBigInt(bHex);
async function ensureClipExtractor() {
    if(a===null||b===null) return 64;
   if (_clipModulePromise) return _clipModulePromise;
    let x=a^b, d=0n; while(x){ d+=(x&1n); x>>=1n; } return Number(d);
  }
   function phashScore(a,b){ const d=ham64(a,b); return 1-(d/64); } // 1..0


   setStatus('Modell laden …');
   // -------- CLIP laden --------
  setProgress(0.08);
  let _clipReady=null;
  async function ensureClip(){
    if(_clipReady) return _clipReady;
    setStatus('Modell laden …'); setProgress(0.08);
    _clipReady = (async ()=>{
      const mod = await import(/* webpackIgnore: true */ CFG.transformersURL);
      mod.env.localModelPath=null;
      mod.env.remoteModels=true;
      mod.env.allowRemoteModels=true;
      mod.env.useBrowserCache=true;
      const pipe = await mod.pipeline('image-feature-extraction', CFG.modelId, { quantized:true });
      log('CLIP ready:', pipe.model?.constructor?.name||'unknown');
      return { mod, pipe };
    })();
    return _clipReady;
  }


   _clipModulePromise = (async () => {
   // -------- Bild → Embedding --------
     const ESM_URL = CFG.transformersURL;
  function fileToImage(file){
     const mod = await import(/* webpackIgnore: true */ ESM_URL);
    return new Promise((res,rej)=>{
      const url=URL.createObjectURL(file);
      const img=new Image(); img.crossOrigin='anonymous';
      img.onload=()=>{ URL.revokeObjectURL(url); res(img); };
      img.onerror=e=>{ URL.revokeObjectURL(url); rej(e); };
      img.src=url;
    });
  }
  function urlToImage(url){
     return new Promise((res,rej)=>{
      const img=new Image(); img.crossOrigin='anonymous';
      img.onload=()=>res(img); img.onerror=rej; img.src=url;
    });
  }
  function toCanvas(img,maxSide){
     const c=document.createElement('canvas');
    let {width:w,height:h}=img;
    const s=Math.min(1, maxSide/Math.max(w,h)); w=Math.round(w*s); h=Math.round(h*s);
    c.width=w; c.height=h; c.getContext('2d').drawImage(img,0,0,w,h);
    return c;
  }
  function normalize(v){ let n=0; for(let i=0;i<v.length;i++) n+=v[i]*v[i]; n=Math.sqrt(n)||1;
    const out=new Float32Array(v.length); for(let i=0;i<v.length;i++) out[i]=v[i]/n; return out; }
  function cosine(a,b){ let s=0; const L=Math.min(a.length,b.length); for(let i=0;i<L;i++) s+=a[i]*b[i]; return s; }


    // NUR remote laden
  async function embedFile(file){
    mod.env.localModelPath  = null;
     const { pipe } = await ensureClip();
    mod.env.remoteModels    = true;
     setStatus('Bild vorbereiten …'); setProgress(0.20);
    mod.env.allowRemoteModels = true;
     const img = await fileToImage(file);
    mod.env.useBrowserCache = true;
    const canvas = toCanvas(img, CFG.maxSide);
 
    setStatus('Bild analysieren …'); setProgress(0.38);
    // Hier liegt der Trick: Wir nutzen explizit "image-feature-extraction",
     const out = await pipe(canvas);
    // damit kein Tokenizer geladen wird.
    const vec = out?.data instanceof Float32Array
     const pipe = await mod.pipeline(
       ? out.data
      'image-feature-extraction',
       : new Float32Array(out?.data || out || []);
      CFG.modelId,
    return normalize(vec);
      { quantized: true }
  }
    );
  async function embedURL(url){
 
     const { pipe } = await ensureClip();
     log('CLIP ready:', pipe.model?.constructor?.name || 'unknown');
     const img = await urlToImage(url);
    return { mod, pipe };
  })();
 
  return _clipModulePromise;
}
 
  // Datei -> HTMLImageElement -> Canvas (Downscale) -> Embedding (Float32Array, normiert)
  async function embedFileImage(file) {
    // Datei als HTMLImageElement laden
     function loadImageFromFile(f) {
      return new Promise((resolve, reject) => {
        const url = URL.createObjectURL(f);
        const img = new Image();
        img.crossOrigin = 'anonymous';
        img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
        img.onerror = (e)=> { URL.revokeObjectURL(url); reject(e); };
        img.src = url;
      });
     }
    // Canvas-Downscale
    function toCanvas(img, maxSide) {
      const c = document.createElement('canvas');
      let { width: w, height: h } = img;
      const scale = Math.min(1, maxSide / Math.max(w, h));
       w = Math.round(w * scale);
       h = Math.round(h * scale);
      c.width = w; c.height = h;
      const ctx = c.getContext('2d');
      ctx.imageSmoothingEnabled = true;
      ctx.drawImage(img, 0, 0, w, h);
      return c;
    }
     const { pipe } = await ensureClipExtractor();
 
    setStatus('Bild vorbereiten …');
    setProgress(0.20);
 
     const img = await loadImageFromFile(file);
     const canvas = toCanvas(img, CFG.maxSide);
     const canvas = toCanvas(img, CFG.maxSide);
    const out = await pipe(canvas);
    const vec = out?.data instanceof Float32Array
      ? out.data
      : new Float32Array(out?.data || out || []);
    return normalize(vec);
  }


     setStatus('Bild analysieren …');
  // -------- Matching --------
     setProgress(0.38);
  async function matchImage(file){
     await loadIndex();
     showPreview(file);


     const out = await pipe(canvas);
    // 1) Query-Embedding
     const q = await embedFile(file);


     // Ausgabe Float32Array normieren
     // 2) Kandidatenliste bestimmen
     const d = out && out.data;
    //    a) wenn Index pHash hat und du *auch* Upload-pHash hättest vorfiltern.
     let vec;
     //      (Wir haben keinen Upload-pHash → fallback: nimm die ersten N)
     if (d instanceof Float32Array) {
     //    b) Oder wenn viele ohne Embed → nimm die ersten N
      vec = d;
     let candidates = INDEX.map((it, i) => ({ i, it, p: (it.phash ? 0.5 : 0.5) }));
     } else if (Array.isArray(d)) {
    // Leichte Sortierung: solche mit Embedding bevorzugen
       // 2D → mitteln
     candidates.sort((a,b)=>{
       vec = Array.isArray(d[0]) ? meanPool2D(d) : new Float32Array(d);
       const ae = INDEX_EMB[a.i] ? 1 : 0;
    } else {
       const be = INDEX_EMB[b.i] ? 1 : 0;
       throw new Error('Embedding-Format unerwartet');
       return be-ae;
     }
     });
     return normalize(vec);
     candidates = candidates.slice(0, Math.max(CFG.topKClip, CFG.topKShow));
  }


  function meanPool2D(arr2d) {
    // 3) Scoring: vorhandene Embeddings direkt; fehlende live aus Thumb
     const rows = arr2d.length;
     setStatus('Kandidaten bewerten …'); setProgress(0.55);
     const dim = rows ? arr2d[0].length : 0;
     const scored=[];
     const sum = new Float32Array(dim);
     let done=0;
     for (let r=0;r<rows;r++) {
     for(const c of candidates){
       const row = arr2d[r];
       try{
      for (let i=0;i<dim;i++) sum[i] += row[i] || 0;
        const vec = INDEX_EMB[c.i] || await embedURL(c.it.thumb);
        const s = cosine(q, vec);
        scored.push({ i:c.i, score:s });
      }catch(e){
        // Thumb-Load-Fehler ignorieren
      }finally{
        done++; setProgress(0.55 + 0.35*(done/candidates.length));
      }
     }
     }
    for (let i=0;i<dim;i++) sum[i] /= (rows || 1);
    return sum;
  }
  function normalize(v) {
    let n=0; for (let i=0;i<v.length;i++) n += v[i]*v[i];
    n = Math.sqrt(n) || 1;
    const out = new Float32Array(v.length);
    for (let i=0;i<v.length;i++) out[i] = v[i]/n;
    return out;
  }
  function cosine(a,b) {
    let s=0; const L=Math.min(a.length,b.length);
    for (let i=0;i<L;i++) s += a[i]*b[i];
    return s; // bei normierten Vektoren = cos
  }


  // ------------------------------------------------------------
     scored.sort((a,b)=> b.score-a.score);
  // Ranking & Rendering
     return scored.slice(0, CFG.topKShow);
  // ------------------------------------------------------------
  function rankByCosine(queryVec) {
    const scores = [];
    for (let i=0;i<INDEX.length;i++) {
      const vec = INDEX_EMB[i];
      if (!vec) continue; // ohne Embedding nicht vergleichbar
      const score = cosine(queryVec, vec);
      scores.push({ i, score });
    }
    // absteigend
     scores.sort((a,b)=> b.score - a.score);
     return scores.slice(0, CFG.topK);
   }
   }


   function renderResults(ranked) {
   function renderResults(ranked){
     const box = qs('ados-scan-results');
     const box=$('ados-scan-results'); if(!box) return;
    if (!box) return;
     box.innerHTML='';
     box.innerHTML = '';
     if(!ranked || !ranked.length){
 
       box.innerHTML='<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
     if (!ranked || !ranked.length) {
       box.innerHTML = '<div class="empty">Keine klaren Treffer. Bitte anderes Foto oder näher am Frontlabel.</div>';
       return;
       return;
     }
     }
 
     ranked.forEach(({i,score})=>{
     ranked.forEach(({ i, score }) => {
       const it=INDEX[i]; const url=mw.util.getUrl((it.title||'').replace(/ /g,'_'));
       const it = INDEX[i];
       const div=document.createElement('div');
      const link = mw.util.getUrl((it.title || '').replace(/ /g,'_'));
       div.className='ados-hit';
       const thumb = it.thumb || '';
       div.style.display='grid';
      const row = document.createElement('div');
       div.style.gridTemplateColumns='60px 1fr auto';
       row.className = 'ados-hit';
       div.style.gap='10px'; div.style.alignItems='center';
       row.style.display = 'grid';
       div.innerHTML =
       row.style.gridTemplateColumns = '60px 1fr auto';
         (it.thumb? `<img src="${it.thumb}" alt="" style="width:60px;height:auto;border-radius:6px;border:1px solid #eee;">` : '<div></div>') +
       row.style.alignItems = 'center';
         `<div><b><a href="${url}">${esc(it.title||'')}</a></b></div>` +
      row.style.gap = '10px';
      row.style.padding = '.35rem 0';
       row.innerHTML =
         (thumb ? `<div><img src="${thumb}" alt="" style="width:60px;height:auto;border-radius:6px;"></div>` : '<div></div>') +
         `<div><b><a href="${link}">${escapeHtml(it.title || '')}</a></b></div>` +
         `<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
         `<div style="color:#666;font-variant-numeric:tabular-nums">${score.toFixed(3)}</div>`;
       box.appendChild(row);
       box.appendChild(div);
     });
     });
   }
   }


  function escapeHtml(s){ return mw.html.escape(String(s||'')); }
   // -------- UI binden --------
 
   let BOUND=false;
   // ------------------------------------------------------------
   function bind(){
  // Bindings
     if(BOUND) return;
  // ------------------------------------------------------------
     const btnCam=$('ados-scan-btn-camera');
   let BOUND = false;
     const btnGal=$('ados-scan-btn-gallery');
   function bindUI() {
     const inCam=$('ados-scan-file-camera');
     if (BOUND) return;
     const inGal=$('ados-scan-file-gallery');
     const btnCam = qs('ados-scan-btn-camera');
     const btnRun=$('ados-scan-run');
     const btnGal = qs('ados-scan-btn-gallery');
     const btnReset=$('ados-scan-reset');
     const inCam   = qs('ados-scan-file-camera');
     const drop=$('ados-scan-drop');
     const inGal   = qs('ados-scan-file-gallery');
     const btnRun = qs('ados-scan-run');
     const btnReset= qs('ados-scan-reset');
     const drop   = qs('ados-scan-drop');


     if (!btnRun || !inCam || !inGal) return;
     if(!btnRun || !inCam || !inGal){ warn('UI unvollständig'); return; }


     // Buttons triggern jeweilige Inputs
     btnCam?.addEventListener('click', ()=> inCam.click());
    if (btnCam)  btnCam.addEventListener('click', ()=> inCam.click());
     btnGal?.addEventListener('click', ()=> inGal.click());
     if (btnGal)  btnGal.addEventListener('click', ()=> inGal.click());


     // Vorschau setzen
     const onPick=e=>{ const f=e.target.files?.[0]; if(f) showPreview(f); };
    function onPick(e) {
      const f = e.target.files && e.target.files[0];
      if (f) showPreview(f);
    }
     inCam.addEventListener('change', onPick);
     inCam.addEventListener('change', onPick);
     inGal.addEventListener('change', onPick);
     inGal.addEventListener('change', onPick);


    // Drag & Drop nur als Zusatz
     if(drop){
     if (drop) {
       drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); });
       drop.addEventListener('dragover', ev => { ev.preventDefault(); drop.classList.add('is-over'); });
       drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
       drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
       drop.addEventListener('drop', ev => {
       drop.addEventListener('drop', e=>{
         ev.preventDefault(); drop.classList.remove('is-over');
         e.preventDefault(); drop.classList.remove('is-over');
         const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
         const f=e.dataTransfer?.files?.[0];
         if (f) {
         if(f){ const dt=new DataTransfer(); dt.items.add(f); inGal.files=dt.files; showPreview(f); }
          // in "Gallery"-Input setzen, damit onRunClick es findet
          const dt = new DataTransfer();
          dt.items.add(f);
          inGal.files = dt.files;
          showPreview(f);
        }
       });
       });
     }
     }


     // Reset
     btnReset?.addEventListener('click', ()=>{
    if (btnReset) btnReset.addEventListener('click', () => {
      inCam.value=''; inGal.value='';
       const p = qs('ados-scan-preview'); if (p) p.innerHTML = '<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
       const p=$('ados-scan-preview'); if(p) p.innerHTML='<div class="note">Noch keine Vorschau. Wähle ein Foto.</div>';
      inCam.value = ''; inGal.value = '';
       const r=$('ados-scan-results'); if(r) r.innerHTML='<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
       const r = qs('ados-scan-results'); if (r) r.innerHTML = '<div class="empty">Hier erscheinen passende Abfüllungen mit Link ins Wiki.</div>';
       setStatus('Bereit.'); setProgress(null);
       setStatus('Bereit.'); setProgress(null);
     });
     });


    // Start
     btnRun.addEventListener('click', async ()=>{
     btnRun.addEventListener('click', onRunClick);
      try{
        const f=inCam.files?.[0] || inGal.files?.[0];
        if(!f){ alert('Bitte zuerst ein Foto aufnehmen oder auswählen.'); return; }
        btnRun.disabled=true; setStatus('Starte …'); setProgress(0.02);
        await loadIndex();
        const ranked = await matchImage(f);
        renderResults(ranked);
        setStatus('Fertig.'); setProgress(null);
      }catch(e){ err(e); setStatus('Fehler bei Erkennung/Suche.'); setProgress(null); }
      finally{ btnRun.disabled=false; }
    });


     BOUND = true;
     BOUND=true;
     log('UI gebunden.');
     log('UI gebunden.');
   }
   }


  async function onRunClick() {
   // -------- Init --------
    try {
   function init(){
      const inCam  = qs('ados-scan-file-camera');
     log('gadget file loaded');
      const inGal  = qs('ados-scan-file-gallery');
     if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', bind, {once:true}); }
      const btnRun  = qs('ados-scan-run');
     else { bind(); }
 
     setTimeout(bind,250); setTimeout(bind,1000);
      const file = (inCam.files && inCam.files[0]) || (inGal.files && inGal.files[0]);
     // Index vorwärmen
      if (!file) { alert('Bitte zuerst ein Foto auswählen oder aufnehmen.'); return; }
 
      btnRun.disabled = true;
      setStatus('Vorbereitung …');
      setProgress(0.02);
 
      // Index sicher laden
      await loadIndex();
 
      // Mind. ein Eintrag mit Embedding vorhanden?
      if (!INDEX_EMB.some(v => v && v.length)) {
        setStatus('Index enthält keine Embeddings – bitte Index mit Embeddings neu erzeugen.');
        setProgress(null);
        btnRun.disabled = false;
        return;
      }
 
      // Embedding für Query-Bild
      const qVec = await embedFileImage(file);    // 0.20–0.40 in ensure / embed
      setProgress(0.70);
 
      // Ranking
      setStatus('Abgleich mit Datenbank …');
      const ranked = rankByCosine(qVec);
 
      setProgress(0.95);
      renderResults(ranked);
      setStatus('Fertig.');
      setProgress(null);
    } catch (e) {
      err('Fehler', e);
      setStatus('Fehler bei Erkennung/Abgleich. Bitte erneut versuchen.');
      setProgress(null);
    } finally {
      const btnRun = qs('ados-scan-run');
      if (btnRun) btnRun.disabled = false;
    }
  }
 
   // ------------------------------------------------------------
  // Init
  // ------------------------------------------------------------
   function init() {
     // Bind nach DOM fertig
     if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', bindUI, { once: true });
     } else {
      bindUI();
    }
 
    // Fallbacks
     setTimeout(bindUI, 250);
    setTimeout(bindUI, 1000);
 
     // (Optional) Index „warm“ laden (nicht blockierend)
     loadIndex().catch(err);
     loadIndex().catch(err);
   }
   }
  // Start
  log('gadget file loaded');
   init();
   init();
})();
})();