Files
openobserve/web/src/utils/folderIcons.ts
T

84 lines
2.9 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// Name -> emoji suggestion for folders. Pure and deterministic: the same name
// always yields the same emoji, so a folder's auto-picked icon never drifts
// between sessions or between users.
import { ALL_EMOJIS } from "@/lib/forms/EmojiPicker/emojiCatalog";
/**
* Neutral, folder-ish emojis used when a name matches no keyword. Picking from
* a small curated pool (rather than the whole catalog) keeps the fallback from
* putting something absurd next to "Team Notes".
*/
const FALLBACK_POOL = ["📁", "🗂️", "🗃️", "📦", "🧩", "🏷️", "📌", "🧰", "📐", "💼"] as const;
/** Words too generic to carry a signal — matching on them yields noise. */
const STOP_WORDS = new Set(["the", "and", "for", "new", "all", "my", "our", "a", "an", "of"]);
/** Distinct, meaningful words in a name. Deduped so "prod prod" isn't 2x. */
function tokenize(name: string): string[] {
return [
...new Set(
name
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 0 && !STOP_WORDS.has(token)),
),
];
}
/**
* How strongly one name token matches one keyword. Higher is better; 0 means no
* match. Length floors keep short tokens from matching half the catalog.
*/
function scoreToken(token: string, keyword: string): number {
if (token === keyword) return 4;
if (token.length >= 3 && keyword.startsWith(token)) return 3;
if (keyword.length >= 4 && token.startsWith(keyword)) return 2;
if (keyword.length >= 5 && token.includes(keyword)) return 1;
return 0;
}
/** FNV-1a — a small stable hash so the fallback is per-name, not per-session. */
function hash(value: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/**
* Best emoji for a folder name. Returns null only for an effectively empty
* name — anything else always gets an icon, so a folder is never blank while
* the user is still typing.
*/
export function suggestFolderIcon(name: string): string | null {
const trimmed = (name ?? "").trim();
if (!trimmed) return null;
const tokens = tokenize(trimmed);
let best = { emoji: "", score: 0 };
// Scores SUM across distinct tokens (taking each token's best keyword), so an
// emoji matched by two words of the name beats one matched by a single word:
// "Kubernetes Cluster Alerts" is about Kubernetes, not about alerts. Catalog
// order breaks genuine ties.
for (const option of ALL_EMOJIS) {
let score = 0;
for (const token of tokens) {
let bestForToken = 0;
for (const keyword of option.keywords) {
bestForToken = Math.max(bestForToken, scoreToken(token, keyword));
}
score += bestForToken;
}
if (score > best.score) best = { emoji: option.token, score };
}
if (best.score > 0) return best.emoji;
return FALLBACK_POOL[hash(trimmed.toLowerCase()) % FALLBACK_POOL.length];
}