Hinweis: Leere nach dem Veröffentlichen den Browser-Cache, um die Änderungen sehen zu können.

  • Firefox/Safari: Umschalttaste drücken und gleichzeitig Aktualisieren anklicken oder entweder Strg+F5 oder Strg+R (⌘+R auf dem Mac) drücken
  • Google Chrome: Umschalttaste+Strg+R (⌘+Umschalttaste+R auf dem Mac) drücken
  • Edge: Strg+F5 drücken oder Strg drücken und gleichzeitig Aktualisieren anklicken
/* global mw */
(() => {
  'use strict';

  // -------- Config --------
  const CFG = {
    indexTitle: (window.LabelScanConfig && window.LabelScanConfig.indexTitle) ||
                'MediaWiki:Gadget-LabelScan-index.json',
    topKShow: 8,            // so viele Treffer anzeigen
    topKClip: 24,           // so viele Kandidaten vor CLIP (per pHash oder einfach die ersten)
    maxSide: 1024,          // Downscale lange Bildkante vorm CLIP
    transformersURL: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.15.0/dist/transformers.min.js',
    modelId: 'Xenova/clip-vit-base-patch32',
    debug: true
  };

  // -------- Utils --------
  const $ = id => document.getElementById(id);
  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||''; }
  function setProgress(p){
    const bar=$('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){
    const url=URL.createObjectURL(file);
    const prev=$('ados-scan-preview');
    if(prev){
      prev.innerHTML='<img alt="Vorschau" style="max-width:100%;height:auto;border-radius:8px;">';
      prev.querySelector('img').src=url;
    }
  }
  function esc(s){ return mw.html.escape(String(s||'')); }

  // -------- Index laden --------
  let INDEX=[], INDEX_EMB=[];
  async function loadIndex(){
    if(INDEX.length) return INDEX;
    setStatus('Index laden …'); setProgress(0.03);
    const raw = mw.util.getUrl(CFG.indexTitle, { action:'raw', ctype:'application/json' });
    const res = await fetch(raw, { cache:'reload' });
    if(!res.ok) throw new Error('Index HTTP '+res.status);
    const data = await res.json();
    if(!Array.isArray(data)) throw new Error('Index ist kein Array');
    INDEX = data.filter(x => x && x.title && x.thumb);
    INDEX_EMB = INDEX.map((it,i)=>{
      if(typeof it.embed === 'string' && it.embed){
        try { return base64ToFloat32(it.embed); } catch(e){ warn('Embed decode', i, it.title, e); }
      }
      return null;
    });
    log('Index geladen:', INDEX.length, 'Einträge');
    setProgress(0.06);
    return INDEX;
  }

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

  // -------- pHash Helfer (optional) --------
  function hexToBigInt(h){ try { return BigInt('0x'+String(h).trim()); } catch{ return null; } }
  function ham64(aHex,bHex){
    const a=hexToBigInt(aHex), b=hexToBigInt(bHex);
    if(a===null||b===null) return 64;
    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

  // -------- CLIP laden --------
  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('feature-extraction', CFG.modelId, { quantized:true });
      log('CLIP ready:', pipe.model?.constructor?.name||'unknown');
      return { mod, pipe };
    })();
    return _clipReady;
  }

  // -------- Bild → Embedding --------
  function fileToImage(file){
    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; }

  async function embedFile(file){
    const { pipe } = await ensureClip();
    setStatus('Bild vorbereiten …'); setProgress(0.20);
    const img = await fileToImage(file);
    const canvas = toCanvas(img, CFG.maxSide);
    setStatus('Bild analysieren …'); setProgress(0.38);
    const out = await pipe(canvas);
    const vec = out?.data instanceof Float32Array
      ? out.data
      : new Float32Array(out?.data || out || []);
    return normalize(vec);
  }
  async function embedURL(url){
    const { pipe } = await ensureClip();
    const img = await urlToImage(url);
    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);
  }

  // -------- Matching --------
  async function matchImage(file){
    await loadIndex();
    showPreview(file);

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

    // 2) Kandidatenliste bestimmen
    //    a) wenn Index pHash hat und du *auch* Upload-pHash hättest → vorfiltern.
    //       (Wir haben keinen Upload-pHash → fallback: nimm die ersten N)
    //    b) Oder wenn viele ohne Embed → nimm die ersten N
    let candidates = INDEX.map((it, i) => ({ i, it, p: (it.phash ? 0.5 : 0.5) }));
    // Leichte Sortierung: solche mit Embedding bevorzugen
    candidates.sort((a,b)=>{
      const ae = INDEX_EMB[a.i] ? 1 : 0;
      const be = INDEX_EMB[b.i] ? 1 : 0;
      return be-ae;
    });
    candidates = candidates.slice(0, Math.max(CFG.topKClip, CFG.topKShow));

    // 3) Scoring: vorhandene Embeddings direkt; fehlende live aus Thumb
    setStatus('Kandidaten bewerten …'); setProgress(0.55);
    const scored=[];
    let done=0;
    for(const c of candidates){
      try{
        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));
      }
    }

    scored.sort((a,b)=> b.score-a.score);
    return scored.slice(0, CFG.topKShow);
  }

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

  // -------- UI binden --------
  let BOUND=false;
  function bind(){
    if(BOUND) return;
    const btnCam=$('ados-scan-btn-camera');
    const btnGal=$('ados-scan-btn-gallery');
    const inCam=$('ados-scan-file-camera');
    const inGal=$('ados-scan-file-gallery');
    const btnRun=$('ados-scan-run');
    const btnReset=$('ados-scan-reset');
    const drop=$('ados-scan-drop');

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

    btnCam?.addEventListener('click', ()=> inCam.click());
    btnGal?.addEventListener('click', ()=> inGal.click());

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

    if(drop){
      drop.addEventListener('dragover', e=>{ e.preventDefault(); drop.classList.add('is-over'); });
      drop.addEventListener('dragleave', ()=> drop.classList.remove('is-over'));
      drop.addEventListener('drop', e=>{
        e.preventDefault(); drop.classList.remove('is-over');
        const f=e.dataTransfer?.files?.[0];
        if(f){ const dt=new DataTransfer(); dt.items.add(f); inGal.files=dt.files; showPreview(f); }
      });
    }

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

    btnRun.addEventListener('click', async ()=>{
      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;
    log('UI gebunden.');
  }

  // -------- Init --------
  function init(){
    log('gadget file loaded');
    if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', bind, {once:true}); }
    else { bind(); }
    setTimeout(bind,250); setTimeout(bind,1000);
    // Index vorwärmen
    loadIndex().catch(err);
  }
  init();
})();