generated from Dicken/dickendock
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
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<string[]> | null = null;
|
|
|
|
function fetchJson(url: string): Promise<unknown> {
|
|
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<string[]> {
|
|
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<IconSearchResult[]> {
|
|
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<string | null> {
|
|
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 "<wort>-" 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<IconSearchResult[]> {
|
|
const names = await getIconNames();
|
|
const seen = new Set<string>();
|
|
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;
|
|
}
|