import { db, ingredients } from "@epicure/db"; export type IngredientAliasIndex = Map; // Case- and accent-insensitive so "café"/"cafe" and "Épinard"/"epinard" // compare equal — NFD splits an accented letter into base letter + // combining mark, then the combining marks (U+0300-U+036F) are stripped. function normalize(name: string): string { return name.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); } /** * Loads every canonical ingredient's name + aliases into a flat * normalized-string -> canonical-ingredient-id map, once per request. Used * to recognize that "sel", "sel fin", and "table salt" are all the same * ingredient, without requiring every recipe/pantry row to carry a stored * ingredientId (they don't — this resolves purely from the free-text name * at comparison time). */ export async function loadIngredientAliasIndex(): Promise { const rows = await db.select({ id: ingredients.id, name: ingredients.name, aliases: ingredients.aliases }).from(ingredients); const index: IngredientAliasIndex = new Map(); for (const row of rows) { index.set(normalize(row.name), row.id); for (const alias of row.aliases) { index.set(normalize(alias), row.id); } } return index; } /** Canonical ingredient id if `rawName` matches a known name/alias exactly * (case/accent/whitespace-insensitive); otherwise the normalized rawName * itself, so unmatched items still compare equal to other unmatched items * with the same text (today's behavior, unchanged for anything not seeded). */ export function resolveIngredientKey(rawName: string, index: IngredientAliasIndex): string { const normalized = normalize(rawName); return index.get(normalized) ?? normalized; } /** Single-name lookup (pantry add/edit). Loads the same small table as * loadIngredientAliasIndex and compares in JS rather than in SQL — accent * stripping via NFD has no simple SQL equivalent without the `unaccent` * extension, which isn't guaranteed to be installed. The ingredients table * is small (tens to low hundreds of rows), so this is cheap. Returns null * when there's no canonical match, meaning the item stays a plain freeform * pantry entry. */ export async function findIngredientIdByName(rawName: string): Promise { const normalized = normalize(rawName); if (!normalized) return null; const index = await loadIngredientAliasIndex(); return index.get(normalized) ?? null; }