Zum Inhalt springen

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

Aus ADOS Wiki
Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
Zeile 1: Zeile 1:
/* LabelScan – Bildähnlichkeit statt OCR
/* LabelScan – Bildähnlichkeit mit CLIP (UMD Loader, robust)
* Benötigt keine Server-Backends. Läuft komplett im Browser.
  Lädt @xenova/transformers (UMD) per <script>, baut einen lokalen Bildindex
* Erstellt einen lokalen Index (CLIP-Embeddings) aus allen Abfüllungsbildern in deinen Kategorien.
  aus Abfüllungs-Thumbnails und sucht die ähnlichsten Seiten.
* Autor: ADOS-Wiki Setup
*/
*/
/* global mw */
/* global mw */
(() => {
(() => {
   'use strict';
   'use strict';


   // ---------- KONFIG ----------
   // ======== KONFIG ========
 
  // Kategorien (genau so, wie sie im Wiki heißen)
   const CATEGORIES = [
   const CATEGORIES = [
     'Alle A Dream of Scotland Abfüllungen',
     'Alle A Dream of Scotland Abfüllungen',
Zeile 25: Zeile 22:
     'Sonderabfüllungen'
     'Sonderabfüllungen'
   ];
   ];
  // Wie groß sollen die Thumbnails fürs Einbetten sein?
   const THUMB_SIZE = 512;
   const THUMB_SIZE = 512;
  // Wieviele Vorschläge anzeigen?
   const TOP_K = 8;
   const TOP_K = 8;
  const IDB = { name: 'ados-labelscan', store: 'index', version: 2 }; // <- Version erhöht


  // IndexedDB-Store (bei Strukturänderung die VERSION erhöhen)
   const UMD_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@3.0.0/dist/transformers.umd.min.js';
   const IDB = { name: 'ados-labelscan', store: 'index', version: 1 };


   // ---------- MINI UI HELPERS ----------
   // ======== UI Helpers ========
 
   const $ = (s) => document.querySelector(s);
   const $ = (sel) => document.querySelector(sel);
   function setStatus(t){ const el = $('#ados-scan-status'); if (el) el.textContent = t || ''; }
   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 setProgress(p) {
   function showPreview(file){ const url=URL.createObjectURL(file); const box=$('#ados-scan-preview'); if(box) box.innerHTML=`<img alt="Vorschau" src="${url}" style="max-width:260px;border-radius:8px">`; }
    const bar = $('#ados-scan-progress');
   function renderResults(items){
    if (!bar) return;
     const box = $('#ados-scan-results'); if(!box) return; box.innerHTML='';
    if (p == null) { bar.hidden = true; bar.value = 0; }
     if(!items || !items.length){ box.innerHTML='<div class="ados-hit">Keine Treffer gefunden.</div>'; return; }
    else { bar.hidden = false; bar.value = Math.max(0, Math.min(1, p)); }
     items.forEach(it=>{
  }
   function showPreview(file) {
    const url = URL.createObjectURL(file);
    const box = $('#ados-scan-preview');
    if (box) box.innerHTML = `<img alt="Vorschau" src="${url}" style="max-width:260px;border-radius:8px">`;
  }
   function renderResults(items) {
     const box = $('#ados-scan-results');
    if (!box) return;
    box.innerHTML = '';
     if (!items || !items.length) {
      box.innerHTML = '<div class="ados-hit">Keine Treffer gefunden.</div>';
      return;
    }
     for (const it of items) {
       const url = mw.util.getUrl(it.title.replace(/ /g,'_'));
       const url = mw.util.getUrl(it.title.replace(/ /g,'_'));
       const div = document.createElement('div');
       const div = document.createElement('div');
Zeile 64: Zeile 42:
       div.innerHTML = `
       div.innerHTML = `
         <a class="thumb" href="${url}"><img alt="" src="${it.thumb}" loading="lazy"></a>
         <a class="thumb" href="${url}"><img alt="" src="${it.thumb}" loading="lazy"></a>
         <div class="meta">
         <div class="meta"><b><a href="${url}">${mw.html.escape(it.title)}</a></b>
          <b><a href="${url}">${mw.html.escape(it.title)}</a></b>
        <div class="sub">Ähnlichkeit: ${(it.score*100).toFixed(1)}%</div></div>`;
          <div class="sub">Ähnlichkeit: ${(it.score*100).toFixed(1)}%</div>
        </div>`;
       box.appendChild(div);
       box.appendChild(div);
     }
     });
   }
   }


   // ---------- INDEXEDDB (sehr klein gehalten) ----------
   // ======== IndexedDB Mini ========
 
   function idbOpen(){
   function idbOpen() {
     return new Promise((res,rej)=>{
     return new Promise((resolve, reject) => {
       const req = indexedDB.open(IDB.name, IDB.version);
       const req = indexedDB.open(IDB.name, IDB.version);
       req.onupgradeneeded = (e) => {
       req.onupgradeneeded = (e)=>{ const db=req.result; if(e.oldVersion<1) db.createObjectStore(IDB.store,{keyPath:'key'}); };
        const db = req.result;
       req.onsuccess=()=>res(req.result); req.onerror=()=>rej(req.error);
        if (e.oldVersion < 1) db.createObjectStore(IDB.store, { keyPath: 'key' });
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }
  async function idbGet(key) {
    const db = await idbOpen();
    return new Promise((resolve, reject) => {
      const tx = db.transaction(IDB.store, 'readonly');
      const st = tx.objectStore(IDB.store);
      const req = st.get(key);
       req.onsuccess = () => resolve(req.result ? req.result.val : null);
      req.onerror = () => reject(req.error);
     });
     });
   }
   }
   async function idbSet(key, val) {
  async function idbGet(key){ const db=await idbOpen(); return new Promise((res,rej)=>{ const tx=db.transaction(IDB.store,'readonly'); const st=tx.objectStore(IDB.store); const r=st.get(key); r.onsuccess=()=>res(r.result?r.result.val:null); r.onerror=()=>rej(r.error);});}
    const db = await idbOpen();
   async function idbSet(key,val){ const db=await idbOpen(); return new Promise((res,rej)=>{ const tx=db.transaction(IDB.store,'readwrite'); const st=tx.objectStore(IDB.store); const r=st.put({key,val,ts:Date.now()}); r.onsuccess=()=>res(); r.onerror=()=>rej(r.error);});}
    return new Promise((resolve, reject) => {
      const tx = db.transaction(IDB.store, 'readwrite');
      const st = tx.objectStore(IDB.store);
      const req = st.put({ key, val, ts: Date.now() });
      req.onsuccess = () => resolve();
      req.onerror = () => reject(req.error);
    });
  }
 
  // ---------- TRANSFORMERS / CLIP ----------


   let clipReady = null;
  // ======== Transformers (UMD) laden ========
   async function ensureCLIP() {
   let clipReady=null;
   function ensureTransformersUMD(){
     if (clipReady) return clipReady;
     if (clipReady) return clipReady;
     clipReady = (async () => {
     clipReady = new Promise((resolve, reject)=>{
       // Laden als ES-Module
       if (window.transformers && window.transformers.pipeline) return resolve(window.transformers);
       const { pipeline } = await import('https://cdn.jsdelivr.net/npm/@xenova/transformers@3.0.0/dist/transformers.min.js');
       const s=document.createElement('script');
       // Image-Feature-Extraktion (CLIP)
      s.src = UMD_URL;
      const extractor = await pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');
      s.async = true;
       return { extractor };
      s.onload = ()=> resolve(window.transformers);
     })();
       s.onerror = ()=> reject(new Error('Transformers UMD konnte nicht geladen werden (CSP/CDN geblockt?)'));
       document.head.appendChild(s);
     });
     return clipReady;
     return clipReady;
   }
   }


   // Normierung & Ähnlichkeit
   // ======== Mathe ========
  function l2norm(vec) {
   function cosine(a,b){ let s=0; for(let i=0;i<a.length;i++) s += a[i]*b[i]; // Vektoren sind schon normalisiert
    let s=0; for (let i=0;i<vec.length;i++) s += vec[i]*vec[i];
    // auf 0..1 hübschen (optional)
    const k = 1/Math.sqrt(s||1); for (let i=0;i<vec.length;i++) vec[i]*=k;
     return Math.max(0, Math.min(1, (s+1)/2));
    return vec;
  }
   function cosine(a, b) {
    let s=0; for (let i=0;i<a.length;i++) s += a[i]*b[i];
     return Math.max(0, Math.min(1, (s+1)/2)); // hübscher 0..1
   }
   }


   // ---------- MEDIAWIKI API ----------
   // ======== MW API ========
 
   async function apiGet(p){ await mw.loader.using('mediawiki.api'); const api=new mw.Api(); return api.get(p); }
   async function apiGet(params) {
   async function pagesFromCategory(cat){
    await mw.loader.using('mediawiki.api');
     const pages=[]; let cont; do{
    const api = new mw.Api();
       const r=await apiGet({ action:'query', list:'categorymembers', cmtitle:'Category:'+cat, cmtype:'page', cmlimit:'max', ...(cont||{}) });
    return api.get(params);
       (r.query?.categorymembers||[]).forEach(it=>pages.push(it.title));
  }
       cont=r.continue;
 
     }while(cont);
   async function pagesFromCategory(cat) {
     const pages = [];
    let cont = undefined;
    do {
       const res = await apiGet({
        action: 'query',
        list: 'categorymembers',
        cmtitle: 'Category:' + cat,
        cmtype: 'page',
        cmlimit: 'max',
        ...(cont || {})
      });
       for (const it of (res.query?.categorymembers || [])) {
        pages.push(it.title);
      }
       cont = res.continue;
     } while (cont);
     return pages;
     return pages;
   }
   }
 
   async function pageThumbs(titles){
   async function pageThumbs(titles) {
     const out=[]; const chunk=(a,n)=>a.length?[a.slice(0,n),...chunk(a.slice(n),n)]:[];
     const out = [];
     for(const batch of chunk(titles,40)){
    // In Batches abfragen
       const r = await apiGet({ action:'query', prop:'pageimages', piprop:'thumbnail', pithumbsize:THUMB_SIZE, titles:batch.join('|'), formatversion:2 });
    const chunk = (arr, n) => arr.length ? [arr.slice(0,n), ...chunk(arr.slice(n), n)] : [];
       (r.query?.pages||[]).forEach(p=>{ const th=p.thumbnail?.source; if(th) out.push({title:p.title, thumb:th}); });
     for (const batch of chunk(titles, 40)) {
       const res = await apiGet({
        action: 'query',
        prop: 'pageimages',
        piprop: 'thumbnail',
        pithumbsize: THUMB_SIZE,
        titles: batch.join('|'),
        formatversion: 2
      });
       for (const p of (res.query?.pages || [])) {
        const th = p.thumbnail?.source;
        if (th) out.push({ title: p.title, thumb: th });
      }
     }
     }
     return out;
     return out;
   }
   }
 
   async function buildGallery(){
   async function buildGallery() {
     const set=new Set();
    // Alle Titles aus allen Kategorien
     for(const c of CATEGORIES){ const list=await pagesFromCategory(c); list.forEach(t=>set.add(t)); }
     const titlesSet = new Set();
     const titles=[...set];
     for (const cat of CATEGORIES) {
     return pageThumbs(titles);
      const list = await pagesFromCategory(cat);
      list.forEach(t => titlesSet.add(t));
    }
     const titles = Array.from(titlesSet);
     const withThumbs = await pageThumbs(titles);
    return withThumbs; // [{title, thumb}]
   }
   }


   // ---------- INDEX AUFBAUEN / LADEN ----------
   // ======== Index bauen/laden ========
 
   async function ensureIndex(report){
   async function ensureIndex(updateProgress) {
     let idx = await idbGet('index-v2');
    // Versuche aus IDB
     if (idx?.items?.length){ report?.(1,1,'Index aus Cache'); return idx; }
     let idx = await idbGet('index-v1');
     if (idx && Array.isArray(idx.items) && idx.items.length) {
      updateProgress?.(1, 1, 'Index aus Cache');
      return idx;
    }


     // Neu aufbauen
     report?.(0,1,'Lade Wiki-Bilder …');
    updateProgress?.(0, 1, 'Lade Wiki-Bilder …');
     const gallery = await buildGallery();
     const gallery = await buildGallery();
     if (!gallery.length) return { items: [] };
     if (!gallery.length) return { items: [] };


     const { extractor } = await ensureCLIP();
    setStatus('Lade CLIP-Modell … (einmalig)');
     const tf = await ensureTransformersUMD();
    // Optional: Pfade für WASM setzen (nur Info-Log)
    try {
      tf.env.allowRemoteModels = true; // default, aber explizit
    } catch(e){ /* ignore */ }


     const items = [];
    const pipe = await tf.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');
     for (let i = 0; i < gallery.length; i++) {
 
       const g = gallery[i];
     const items=[];
       try {
     for(let i=0;i<gallery.length;i++){
        updateProgress?.(i, gallery.length, `Embedding ${i+1}/${gallery.length}: ${g.title}`);
       const g=gallery[i];
         const emb = await extractor(g.thumb, { pooling: 'mean', normalize: true }); // Float32Array
       report?.(i, gallery.length, `Embedding ${i+1}/${gallery.length}: ${g.title}`);
        // In normales Array konvertieren (IDB-kompatibel)
      try{
         items.push({ title: g.title, thumb: g.thumb, vec: Array.from(emb.data) });
         const emb = await pipe(g.thumb, { pooling:'mean', normalize:true }); // Float32Array
       } catch (e) {
         items.push({ title:g.title, thumb:g.thumb, vec:Array.from(emb.data) });
        // Ignore einzelne Fehlschläge
       }catch(e){
         console.warn('[LabelScan] Embedding fail for', g.title, e);
         console.warn('[LabelScan] Embedding-Fehler', g.title, e);
       }
       }
     }
     }
 
     const index={ builtAt:Date.now(), items };
     const index = { builtAt: Date.now(), items };
     await idbSet('index-v2', index);
     await idbSet('index-v1', index);
     report?.(1,1,'Index gespeichert');
     updateProgress?.(1, 1, 'Index gespeichert');
     return index;
     return index;
   }
   }


   // ---------- SUCHE ----------
   // ======== Suche ========
 
   async function runSearch(file){
   async function runSearch(file) {
     setProgress(0); setStatus('Baue/ lade Bild-Index …');
     setProgress(0); setStatus('Baue/ lade Bild-Index …');
     const index = await ensureIndex((i, n, msg) => {
     const index = await ensureIndex((i,n,msg)=>{ setStatus(msg||'Index…'); setProgress(n? i/n : null); });
      setStatus(msg || 'Erstelle Index …'); setProgress(n ? i/n : null);
     if(!index.items.length){ renderResults([]); setProgress(null); setStatus('Kein Bildmaterial gefunden.'); return; }
    });
     if (!index.items.length) {
      renderResults([]); setProgress(null);
      setStatus('Kein Bildmaterial gefunden.');
      return;
    }


     setStatus('Berechne Embedding vom Foto …'); setProgress(0.05);
     setStatus('Berechne Embedding vom Foto …'); setProgress(0.1);
     const { extractor } = await ensureCLIP();
     const tf = await ensureTransformersUMD();
    const pipe = await tf.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');


    // Datei in DataURL umwandeln, damit @xenova/transformers sie laden kann
     const dataURL = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(file); });
     const dataURL = await new Promise((res, rej) => {
      const r = new FileReader();
      r.onload = () => res(r.result);
      r.onerror = rej;
      r.readAsDataURL(file);
    });


     const q = await extractor(dataURL, { pooling: 'mean', normalize: true });
     const q = await pipe(dataURL, { pooling:'mean', normalize:true });
     const qVec = q.data; // Float32Array, bereits normalisiert
     const qv = q.data;


     setStatus('Finde ähnlichste Abfüllungen …'); setProgress(0.15);
     setStatus('Finde ähnlichste Abfüllungen …'); setProgress(0.2);
 
     const scored = index.items.map(it=>({ title:it.title, thumb:it.thumb, score:cosine(qv, it.vec) }))
    // Scores
                              .sort((a,b)=>b.score-a.score)
     const scored = index.items.map(it => ({
                              .slice(0, TOP_K);
      title: it.title,
     renderResults(scored);
      thumb: it.thumb,
      score: cosine(qVec, it.vec)
    }));
    scored.sort((a,b) => b.score - a.score);
    const top = scored.slice(0, TOP_K);
 
     renderResults(top);
     setProgress(null); setStatus('Fertig.');
     setProgress(null); setStatus('Fertig.');
   }
   }


   // ---------- BINDING ----------
   // ======== Binding ========
  function bind(){
    const runBtn=$('#ados-scan-run'), fileIn=$('#ados-scan-file'), bigBtn=$('#ados-scan-bigbtn');
    if(!runBtn||!fileIn) return;
    if(runBtn.dataset.bound==='1') return; runBtn.dataset.bound='1';


  function bind() {
     bigBtn && bigBtn.addEventListener('click', ()=>fileIn.click());
     const runBtn = $('#ados-scan-run');
     fileIn.addEventListener('change', function(){ if(this.files && this.files[0]) showPreview(this.files[0]); });
    const fileIn = $('#ados-scan-file');
     const bigBtn = $('#ados-scan-bigbtn');
    if (!runBtn || !fileIn) return;


    if (runBtn.dataset.bound === '1') return;
     runBtn.addEventListener('click', async (ev)=>{
    runBtn.dataset.bound = '1';
 
    if (bigBtn) bigBtn.addEventListener('click', () => fileIn.click());
    fileIn.addEventListener('change', function () { if (this.files && this.files[0]) showPreview(this.files[0]); });
 
     runBtn.addEventListener('click', async (ev) => {
       ev.preventDefault();
       ev.preventDefault();
       if (!(fileIn.files && fileIn.files[0])) { alert('Bitte ein Foto auswählen oder aufnehmen.'); return; }
       if(!(fileIn.files && fileIn.files[0])){ alert('Bitte ein Foto auswählen oder aufnehmen.'); return; }
       runBtn.disabled = true; runBtn.textContent = 'Erkenne …';
       runBtn.disabled=true; runBtn.textContent='Erkenne …';
       try { await runSearch(fileIn.files[0]); }
       try { await runSearch(fileIn.files[0]); }
       catch (e) { console.error(e); setStatus('Fehler. Bitte erneut versuchen.'); }
       catch(e){
       finally { runBtn.disabled = false; runBtn.textContent = '🔍 Erkennen & suchen'; }
        console.error('[LabelScan] Fehler:', e);
        setStatus(
          'Fehler beim Laden/Verarbeiten. Prüfe Konsole. '+
          'Häufige Ursachen: CDN/CSP blockiert oder Netzwerk.'
        );
       } finally {
        runBtn.disabled=false; runBtn.textContent='🔍 Erkennen & suchen';
      }
     });
     });
   }
   }


  // Automatisch binden
   if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', bind); else bind();
   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind);
   new MutationObserver(bind).observe(document.documentElement, { childList:true, subtree:true });
  else bind();
   new MutationObserver(bind).observe(document.documentElement, { childList: true, subtree: true });
 
})();
})();

Version vom 6. November 2025, 21:28 Uhr

/* LabelScan – Bildähnlichkeit mit CLIP (UMD Loader, robust)
   Lädt @xenova/transformers (UMD) per <script>, baut einen lokalen Bildindex
   aus Abfüllungs-Thumbnails und sucht die ähnlichsten Seiten.
*/
/* global mw */
(() => {
  'use strict';

  // ======== KONFIG ========
  const CATEGORIES = [
    'Alle A Dream of Scotland Abfüllungen',
    'Alle A Dream of Ireland Abfüllungen',
    'Alle A Dream of... – Der Rest der Welt Abfüllungen',
    'Cigar Malt Übersicht',
    'Rumbastic Abfüllungen',
    'The Tasteful 8',
    'Còmhlan Abfüllungen',
    'Friendly Mr. Z Whiskytainment Abfüllungen',
    'Die Whisky Elfen Abfüllungen',
    'The Fine Art of Whisky Abfüllungen',
    'The Forbidden Kingdom',
    'Sonderabfüllungen'
  ];
  const THUMB_SIZE = 512;
  const TOP_K = 8;
  const IDB = { name: 'ados-labelscan', store: 'index', version: 2 }; // <- Version erhöht

  const UMD_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@3.0.0/dist/transformers.umd.min.js';

  // ======== UI Helpers ========
  const $ = (s) => document.querySelector(s);
  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 box=$('#ados-scan-preview'); if(box) box.innerHTML=`<img alt="Vorschau" src="${url}" style="max-width:260px;border-radius:8px">`; }
  function renderResults(items){
    const box = $('#ados-scan-results'); if(!box) return; box.innerHTML='';
    if(!items || !items.length){ box.innerHTML='<div class="ados-hit">Keine Treffer gefunden.</div>'; return; }
    items.forEach(it=>{
      const url = mw.util.getUrl(it.title.replace(/ /g,'_'));
      const div = document.createElement('div');
      div.className = 'ados-hit';
      div.innerHTML = `
        <a class="thumb" href="${url}"><img alt="" src="${it.thumb}" loading="lazy"></a>
        <div class="meta"><b><a href="${url}">${mw.html.escape(it.title)}</a></b>
        <div class="sub">Ähnlichkeit: ${(it.score*100).toFixed(1)}%</div></div>`;
      box.appendChild(div);
    });
  }

  // ======== IndexedDB Mini ========
  function idbOpen(){
    return new Promise((res,rej)=>{
      const req = indexedDB.open(IDB.name, IDB.version);
      req.onupgradeneeded = (e)=>{ const db=req.result; if(e.oldVersion<1) db.createObjectStore(IDB.store,{keyPath:'key'}); };
      req.onsuccess=()=>res(req.result); req.onerror=()=>rej(req.error);
    });
  }
  async function idbGet(key){ const db=await idbOpen(); return new Promise((res,rej)=>{ const tx=db.transaction(IDB.store,'readonly'); const st=tx.objectStore(IDB.store); const r=st.get(key); r.onsuccess=()=>res(r.result?r.result.val:null); r.onerror=()=>rej(r.error);});}
  async function idbSet(key,val){ const db=await idbOpen(); return new Promise((res,rej)=>{ const tx=db.transaction(IDB.store,'readwrite'); const st=tx.objectStore(IDB.store); const r=st.put({key,val,ts:Date.now()}); r.onsuccess=()=>res(); r.onerror=()=>rej(r.error);});}

  // ======== Transformers (UMD) laden ========
  let clipReady=null;
  function ensureTransformersUMD(){
    if (clipReady) return clipReady;
    clipReady = new Promise((resolve, reject)=>{
      if (window.transformers && window.transformers.pipeline) return resolve(window.transformers);
      const s=document.createElement('script');
      s.src = UMD_URL;
      s.async = true;
      s.onload = ()=> resolve(window.transformers);
      s.onerror = ()=> reject(new Error('Transformers UMD konnte nicht geladen werden (CSP/CDN geblockt?)'));
      document.head.appendChild(s);
    });
    return clipReady;
  }

  // ======== Mathe ========
  function cosine(a,b){ let s=0; for(let i=0;i<a.length;i++) s += a[i]*b[i]; // Vektoren sind schon normalisiert
    // auf 0..1 hübschen (optional)
    return Math.max(0, Math.min(1, (s+1)/2));
  }

  // ======== MW API ========
  async function apiGet(p){ await mw.loader.using('mediawiki.api'); const api=new mw.Api(); return api.get(p); }
  async function pagesFromCategory(cat){
    const pages=[]; let cont; do{
      const r=await apiGet({ action:'query', list:'categorymembers', cmtitle:'Category:'+cat, cmtype:'page', cmlimit:'max', ...(cont||{}) });
      (r.query?.categorymembers||[]).forEach(it=>pages.push(it.title));
      cont=r.continue;
    }while(cont);
    return pages;
  }
  async function pageThumbs(titles){
    const out=[]; const chunk=(a,n)=>a.length?[a.slice(0,n),...chunk(a.slice(n),n)]:[];
    for(const batch of chunk(titles,40)){
      const r = await apiGet({ action:'query', prop:'pageimages', piprop:'thumbnail', pithumbsize:THUMB_SIZE, titles:batch.join('|'), formatversion:2 });
      (r.query?.pages||[]).forEach(p=>{ const th=p.thumbnail?.source; if(th) out.push({title:p.title, thumb:th}); });
    }
    return out;
  }
  async function buildGallery(){
    const set=new Set();
    for(const c of CATEGORIES){ const list=await pagesFromCategory(c); list.forEach(t=>set.add(t)); }
    const titles=[...set];
    return pageThumbs(titles);
  }

  // ======== Index bauen/laden ========
  async function ensureIndex(report){
    let idx = await idbGet('index-v2');
    if (idx?.items?.length){ report?.(1,1,'Index aus Cache'); return idx; }

    report?.(0,1,'Lade Wiki-Bilder …');
    const gallery = await buildGallery();
    if (!gallery.length) return { items: [] };

    setStatus('Lade CLIP-Modell … (einmalig)');
    const tf = await ensureTransformersUMD();
    // Optional: Pfade für WASM setzen (nur Info-Log)
    try {
      tf.env.allowRemoteModels = true; // default, aber explizit
    } catch(e){ /* ignore */ }

    const pipe = await tf.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');

    const items=[];
    for(let i=0;i<gallery.length;i++){
      const g=gallery[i];
      report?.(i, gallery.length, `Embedding ${i+1}/${gallery.length}: ${g.title}`);
      try{
        const emb = await pipe(g.thumb, { pooling:'mean', normalize:true }); // Float32Array
        items.push({ title:g.title, thumb:g.thumb, vec:Array.from(emb.data) });
      }catch(e){
        console.warn('[LabelScan] Embedding-Fehler', g.title, e);
      }
    }
    const index={ builtAt:Date.now(), items };
    await idbSet('index-v2', index);
    report?.(1,1,'Index gespeichert');
    return index;
  }

  // ======== Suche ========
  async function runSearch(file){
    setProgress(0); setStatus('Baue/ lade Bild-Index …');
    const index = await ensureIndex((i,n,msg)=>{ setStatus(msg||'Index…'); setProgress(n? i/n : null); });
    if(!index.items.length){ renderResults([]); setProgress(null); setStatus('Kein Bildmaterial gefunden.'); return; }

    setStatus('Berechne Embedding vom Foto …'); setProgress(0.1);
    const tf = await ensureTransformersUMD();
    const pipe = await tf.pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');

    const dataURL = await new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(file); });

    const q = await pipe(dataURL, { pooling:'mean', normalize:true });
    const qv = q.data;

    setStatus('Finde ähnlichste Abfüllungen …'); setProgress(0.2);
    const scored = index.items.map(it=>({ title:it.title, thumb:it.thumb, score:cosine(qv, it.vec) }))
                              .sort((a,b)=>b.score-a.score)
                              .slice(0, TOP_K);
    renderResults(scored);
    setProgress(null); setStatus('Fertig.');
  }

  // ======== Binding ========
  function bind(){
    const runBtn=$('#ados-scan-run'), fileIn=$('#ados-scan-file'), bigBtn=$('#ados-scan-bigbtn');
    if(!runBtn||!fileIn) return;
    if(runBtn.dataset.bound==='1') return; runBtn.dataset.bound='1';

    bigBtn && bigBtn.addEventListener('click', ()=>fileIn.click());
    fileIn.addEventListener('change', function(){ if(this.files && this.files[0]) showPreview(this.files[0]); });

    runBtn.addEventListener('click', async (ev)=>{
      ev.preventDefault();
      if(!(fileIn.files && fileIn.files[0])){ alert('Bitte ein Foto auswählen oder aufnehmen.'); return; }
      runBtn.disabled=true; runBtn.textContent='Erkenne …';
      try { await runSearch(fileIn.files[0]); }
      catch(e){
        console.error('[LabelScan] Fehler:', e);
        setStatus(
          'Fehler beim Laden/Verarbeiten. Prüfe Konsole. '+
          'Häufige Ursachen: CDN/CSP blockiert oder Netzwerk.'
        );
      } finally {
        runBtn.disabled=false; runBtn.textContent='🔍 Erkennen & suchen';
      }
    });
  }

  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', bind); else bind();
  new MutationObserver(bind).observe(document.documentElement, { childList:true, subtree:true });
})();