import { request } from "node:https"; const TREE_URL = "https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json"; const CDN_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 Tag let cachedNames: string[] | null = null; let cachedAt = 0; let inFlight: Promise | null = null; function fetchJson(url: string): Promise { return new Promise((resolve, reject) => { const req = request(url, { timeout: 8000 }, (res) => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}`)); res.resume(); return; } const chunks: Buffer[] = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8"))); } catch (e) { reject(e); } }); }); req.on("timeout", () => req.destroy(new Error("Timeout"))); req.on("error", reject); req.end(); }); } /** * Lädt (und cacht 24h im Speicher) die Liste aller Icon-Namen aus dem * homarr-labs/dashboard-icons-Projekt (freie Icon-Sammlung speziell für * Homelab-/Selfhosted-Dashboards, via jsDelivr-CDN ausgeliefert). * * Bewusst als einzige Stelle im Projekt, die tatsächlich einen externen * Dienst kontaktiert (abgesehen vom eigentlichen Aufruf der Dienste selbst) * - das Icon-Datenbank-Feature wurde ausdrücklich so gewünscht. Schlägt der * Abruf fehl (kein Internetzugang, Dienst nicht erreichbar), liefert die * Suche einfach keine Treffer statt einen Fehler zu werfen. */ async function getIconNames(): Promise { if (cachedNames && Date.now() - cachedAt < CACHE_TTL_MS) return cachedNames; if (inFlight) return inFlight; inFlight = (async () => { try { const tree = (await fetchJson(TREE_URL)) as { png?: string[] }; const names = (tree.png ?? []).map((f) => f.replace(/\.png$/, "")); cachedNames = names; cachedAt = Date.now(); return names; } catch { // Kein Absturz, nur keine Treffer - die restliche Favicon-Auswahl // (bereits im System verwendete Favicons) funktioniert unabhängig davon weiter. return cachedNames ?? []; } finally { inFlight = null; } })(); return inFlight; } export interface IconSearchResult { name: string; url: string; } export async function searchIconDb(query: string, limit = 24): Promise { const q = query.trim().toLowerCase(); if (q.length < 2) return []; const names = await getIconNames(); const matches = names.filter((n) => n.includes(q)).slice(0, limit); return matches.map((name) => ({ name, url: `${CDN_BASE}/${name}.png` })); } /** * Begriffe, die beim Zerlegen eines Dienstnamens in Wörter komplett * ignoriert werden - reine Infrastruktur-/Container-Präfixe, nie ein * sinnvoller Icon-Treffer ("LXC Adguard" soll nach "Adguard" suchen, nicht * nach "LXC"). */ const STOPWORDS = new Set([ "lxc", "vm", "docker", "container", "srv", "ct", "instance", "the", "der", "die", "das", ]); /** * Generische Kategorie-Wörter - beschreiben WAS etwas ist, nicht WELCHES * Produkt es ist ("Cam Reolink Keller" soll primär nach "Reolink" suchen, * nicht nach "Cam"). Werden nicht komplett verworfen (manchmal ist die * Kategorie das einzige brauchbare Wort), aber erst als letzter Ausweg * versucht, nachdem alle spezifischeren Wörter erfolglos blieben. */ const GENERIC_WORDS = new Set([ "cam", "camera", "media", "print", "printer", "bridge", "hub", "sync", "box", "sensor", "plug", "light", "dock", "home", "server", "service", "app", "web", "tv", "cast", "stream", "monitor", "gateway", "node", "backup", "storage", "network", "device", "system", ]); function normalize(s: string): string { return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); } /** Wörter in ursprünglicher Reihenfolge (für Bigramme wie "philips-hue" wichtig). */ function significantWords(name: string): string[] { return name .split(/[\s_-]+/) .map((w) => normalize(w)) .filter((w) => w.length >= 3 && !STOPWORDS.has(w)); } /** Aufeinanderfolgende Wortpaare in Original-Reihenfolge, z. B. "philips hue bridge" -> ["philips-hue", "hue-bridge"]. */ function bigrams(words: string[]): string[] { const out: string[] = []; for (let i = 0; i < words.length - 1; i++) out.push(`${words[i]}-${words[i + 1]}`); return out; } /** Spezifische Wörter zuerst (längste zuerst - meist Markennamen), generische zuletzt. */ function byPriority(words: string[]): string[] { const specific = words.filter((w) => !GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length); const generic = words.filter((w) => GENERIC_WORDS.has(w)).sort((a, b) => b.length - a.length); return [...specific, ...generic]; } function exactMatch(candidate: string, names: string[]): string | null { if (names.includes(candidate)) return candidate; const collapsed = candidate.replace(/-/g, ""); return names.find((n) => n.replace(/-/g, "") === collapsed) ?? null; } /** * Sucht den besten Treffer in der Icon-Datenbank für einen Dienst-/ * Software-Namen ("Plex", "LXC Adguard", "Philips Hue Bridge") - für den * Fall, dass der Scanner selbst kein Favicon gefunden hat (siehe * networkScanner.ts). Läuft unbeaufsichtigt während eines Scans, liefert * deshalb nur EINEN Treffer und ausschließlich über exakte (bzw. nach * Entfernen von Bindestrichen exakte) Übereinstimmungen - keine * Teilstring-Suche, die wäre für eine automatische, unbeaufsichtigte * Zuordnung zu unzuverlässig. * * Reihenfolge: ganzer Name -> Wortpaare in Originalreihenfolge (erkennt * "Philips Hue" als Einheit) -> einzelne Wörter, spezifische (längere, * nicht-generische) zuerst. */ export async function findBestIconMatch(name: string): Promise { const names = await getIconNames(); const wholeName = normalize(name); if (wholeName.length >= 2) { const match = exactMatch(wholeName, names); if (match) return `${CDN_BASE}/${match}.png`; } const words = significantWords(name); for (const pair of bigrams(words)) { const match = exactMatch(pair, names); if (match) return `${CDN_BASE}/${match}.png`; } for (const word of byPriority(words)) { const match = exactMatch(word, names); if (match) return `${CDN_BASE}/${match}.png`; } // Letzter, bewusst noch immer präziser Versuch: ein Name wie "adguard" // trifft "adguard-home" nicht exakt (auch nicht nach Bindestrich-Vergleich), // ist aber ein eindeutiger Wortanfang-Treffer, wenn KEIN anderer Icon-Name // mit "-" beginnt. Nur dann automatisch übernehmen - bei mehreren // möglichen Treffern bleibt es bei "kein Favicon" (dafür gibt es die // interaktive Auswahl mit mehreren Kandidaten). for (const candidate of [wholeName, ...byPriority(words)]) { if (candidate.length < 3) continue; const prefixMatches = names.filter((n) => n === candidate || n.startsWith(`${candidate}-`)); if (prefixMatches.length === 1) return `${CDN_BASE}/${prefixMatches[0]}.png`; if (prefixMatches.length > 1) { // Mehrere Treffer (z. B. "adguard-home" UND "adguard-home-sync" für // "adguard") - den kürzesten (meist der "Haupt"-Name der Software) // automatisch nehmen, aber NUR wenn er eindeutig der kürzeste ist, // sonst bleibt es zweideutig (dafür gibt es die interaktive Auswahl). const sorted = [...prefixMatches].sort((a, b) => a.length - b.length); if (sorted[0].length < sorted[1].length) return `${CDN_BASE}/${sorted[0]}.png`; } } return null; } /** * Wie findBestIconMatch, liefert aber ALLE plausiblen Kandidaten statt nur * den ersten - für die interaktive "Fehlende Favicons ergänzen"-Übersicht, * wo der Mensch selbst auswählt, welcher Treffer stimmt. * * Reihenfolge: erst alle exakten Treffer (ganzer Name, Wortpaare, einzelne * Wörter nach Priorität), DANACH erst - falls noch Platz bis zum Limit ist - * Teilstring-Treffer, wieder erst über spezifische Wörter. Generische * Kategorie-Wörter (siehe GENERIC_WORDS) werden für die Teilstring-Suche nur * herangezogen, wenn die spezifischen Wörter GAR NICHTS geliefert haben - * genau das war das gemeldete Problem ("Cam Blink Sync" fand nur * Kamera-Zubehör über "Cam" statt "Blink"). */ export async function suggestIconsForServiceName(name: string, limit = 8): Promise { const names = await getIconNames(); const seen = new Set(); const results: IconSearchResult[] = []; function addIfNew(iconName: string) { if (seen.has(iconName) || results.length >= limit) return; seen.add(iconName); results.push({ name: iconName, url: `${CDN_BASE}/${iconName}.png` }); } const words = significantWords(name); const specificWords = byPriority(words).filter((w) => !GENERIC_WORDS.has(w)); const genericWords = byPriority(words).filter((w) => GENERIC_WORDS.has(w)); const wholeName = normalize(name); if (wholeName.length >= 2) { const exact = exactMatch(wholeName, names); if (exact) addIfNew(exact); } for (const pair of bigrams(words)) { const exact = exactMatch(pair, names); if (exact) addIfNew(exact); } for (const word of specificWords) { const exact = exactMatch(word, names); if (exact) addIfNew(exact); } // Teilstring-Suche nur über spezifische Wörter, solange noch Platz ist. for (const word of specificWords) { if (results.length >= limit) break; for (const n of names) { if (results.length >= limit) break; if (n.includes(word)) addIfNew(n); } } // Generische Wörter (z. B. "cam", "media") nur als allerletzter Ausweg, // wenn spezifische Wörter überhaupt nichts geliefert haben. if (results.length === 0) { for (const word of genericWords) { const exact = exactMatch(word, names); if (exact) addIfNew(exact); } for (const word of genericWords) { if (results.length >= limit) break; for (const n of names) { if (results.length >= limit) break; if (n.includes(word)) addIfNew(n); } } } return results; }