mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Design at: https://github.com/openobserve/designs/blob/main/ux/folder-icons/design.md --------- Co-authored-by: Kirtan Thakkar <kirtan@kiara.tech> Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech> Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
84 lines
2.9 KiB
TypeScript
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];
|
|
}
|