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
/* LabelScan – Bildähnlichkeit statt OCR
 * Benötigt keine Server-Backends. Läuft komplett im Browser.
 * Erstellt einen lokalen Index (CLIP-Embeddings) aus allen Abfüllungsbildern in deinen Kategorien.
 * Autor: ADOS-Wiki Setup
 */
/* global mw */
(() => {
  'use strict';

  // ---------- KONFIG ----------

  // Kategorien (genau so, wie sie im Wiki heißen)
  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'
  ];

  // Wie groß sollen die Thumbnails fürs Einbetten sein?
  const THUMB_SIZE = 512;

  // Wieviele Vorschläge anzeigen?
  const TOP_K = 8;

  // IndexedDB-Store (bei Strukturänderung die VERSION erhöhen)
  const IDB = { name: 'ados-labelscan', store: 'index', version: 1 };

  // ---------- MINI UI HELPERS ----------

  const $ = (sel) => document.querySelector(sel);
  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;
    }
    for (const it of items) {
      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 (sehr klein gehalten) ----------

  function idbOpen() {
    return new Promise((resolve, reject) => {
      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 = () => 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) {
    const db = await idbOpen();
    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;
  async function ensureCLIP() {
    if (clipReady) return clipReady;
    clipReady = (async () => {
      // Laden als ES-Module
      const { pipeline } = await import('https://cdn.jsdelivr.net/npm/@xenova/transformers@3.0.0/dist/transformers.min.js');
      // Image-Feature-Extraktion (CLIP)
      const extractor = await pipeline('image-feature-extraction', 'Xenova/clip-vit-base-patch32');
      return { extractor };
    })();
    return clipReady;
  }

  // Normierung & Ähnlichkeit
  function l2norm(vec) {
    let s=0; for (let i=0;i<vec.length;i++) s += vec[i]*vec[i];
    const k = 1/Math.sqrt(s||1); for (let i=0;i<vec.length;i++) vec[i]*=k;
    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 ----------

  async function apiGet(params) {
    await mw.loader.using('mediawiki.api');
    const api = new mw.Api();
    return api.get(params);
  }

  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;
  }

  async function pageThumbs(titles) {
    const out = [];
    // In Batches abfragen
    const chunk = (arr, n) => arr.length ? [arr.slice(0,n), ...chunk(arr.slice(n), n)] : [];
    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;
  }

  async function buildGallery() {
    // Alle Titles aus allen Kategorien
    const titlesSet = new Set();
    for (const cat of CATEGORIES) {
      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 ----------

  async function ensureIndex(updateProgress) {
    // Versuche aus IDB
    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
    updateProgress?.(0, 1, 'Lade Wiki-Bilder …');
    const gallery = await buildGallery();
    if (!gallery.length) return { items: [] };

    const { extractor } = await ensureCLIP();

    const items = [];
    for (let i = 0; i < gallery.length; i++) {
      const g = gallery[i];
      try {
        updateProgress?.(i, gallery.length, `Embedding ${i+1}/${gallery.length}: ${g.title}`);
        const emb = await extractor(g.thumb, { pooling: 'mean', normalize: true }); // Float32Array
        // In normales Array konvertieren (IDB-kompatibel)
        items.push({ title: g.title, thumb: g.thumb, vec: Array.from(emb.data) });
      } catch (e) {
        // Ignore einzelne Fehlschläge
        console.warn('[LabelScan] Embedding fail for', g.title, e);
      }
    }

    const index = { builtAt: Date.now(), items };
    await idbSet('index-v1', index);
    updateProgress?.(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 || 'Erstelle 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.05);
    const { extractor } = await ensureCLIP();

    // 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 q = await extractor(dataURL, { pooling: 'mean', normalize: true });
    const qVec = q.data; // Float32Array, bereits normalisiert

    setStatus('Finde ähnlichste Abfüllungen …'); setProgress(0.15);

    // Scores
    const scored = index.items.map(it => ({
      title: it.title,
      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.');
  }

  // ---------- BINDING ----------

  function bind() {
    const runBtn = $('#ados-scan-run');
    const fileIn = $('#ados-scan-file');
    const bigBtn = $('#ados-scan-bigbtn');
    if (!runBtn || !fileIn) return;

    if (runBtn.dataset.bound === '1') return;
    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();
      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(e); setStatus('Fehler. Bitte erneut versuchen.'); }
      finally { runBtn.disabled = false; runBtn.textContent = '🔍 Erkennen & suchen'; }
    });
  }

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

})();