feat: expand ingredient-alias seed list to ~137 entries, ignore accents in name matching (v0.86.0)
Ingredient-alias matching (pantry, can-cook, auto-deduct, shopping-list generation) now covers common bilingual EN/FR ingredients across all 8 grocery categories, up from ~10 staples. Also: matching now ignores accents as well as case (NFD-normalize + strip combining marks) in both the alias resolver (lib/ingredient-match.ts) and the older name-fallback matcher (pantry-shopping-match.ts) — "café"/"Café"/"cafe" and "Épinard"/"epinard" all recognize as the same ingredient without needing every accent variant manually listed as an alias. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.85.0";
|
||||
export const APP_VERSION = "0.86.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,16 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.86.0",
|
||||
date: "2026-07-24 21:30",
|
||||
added: [
|
||||
"Ingredient-alias matching now covers ~137 common bilingual EN/FR ingredients across all grocery categories (was ~10) — produce, dairy/eggs, meat/seafood, bakery, frozen, pantry staples, spices/condiments, and beverages.",
|
||||
],
|
||||
fixed: [
|
||||
"Ingredient name matching (pantry, can-cook, auto-deduct, shopping-list generation) now ignores accents as well as case — \"café\"/\"Café\"/\"cafe\" and \"Épinard\"/\"epinard\" all recognized as the same ingredient.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.85.0",
|
||||
date: "2026-07-24 21:00",
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { db, ingredients, sql } from "@epicure/db";
|
||||
import { db, ingredients } from "@epicure/db";
|
||||
|
||||
export type IngredientAliasIndex = Map<string, string>;
|
||||
|
||||
// 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();
|
||||
return name.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every canonical ingredient's name + aliases into a flat
|
||||
* lowercased-string -> canonical-ingredient-id map, once per request. Used
|
||||
* 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
|
||||
@@ -27,25 +30,24 @@ export async function loadIngredientAliasIndex(): Promise<IngredientAliasIndex>
|
||||
}
|
||||
|
||||
/** Canonical ingredient id if `rawName` matches a known name/alias exactly
|
||||
* (case/whitespace-insensitive); otherwise the normalized rawName itself,
|
||||
* so unmatched items still compare equal to other unmatched items with the
|
||||
* exact same text (today's behavior, unchanged for anything not seeded). */
|
||||
* (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) — a direct query rather than
|
||||
* loading the whole table, since this runs once per add/rename rather than
|
||||
* in a loop. Returns null when there's no canonical match, meaning the item
|
||||
* stays a plain freeform pantry entry. */
|
||||
/** 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<string | null> {
|
||||
const normalized = normalize(rawName);
|
||||
if (!normalized) return null;
|
||||
const [match] = await db
|
||||
.select({ id: ingredients.id })
|
||||
.from(ingredients)
|
||||
.where(sql`lower(${ingredients.name}) = ${normalized} or exists (select 1 from unnest(${ingredients.aliases}) a where lower(a) = ${normalized})`)
|
||||
.limit(1);
|
||||
return match?.id ?? null;
|
||||
const index = await loadIngredientAliasIndex();
|
||||
return index.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ export function scoreRecipesAgainstPantry<T>(
|
||||
pantry: { rawName: string; expiresAt: Date | null }[],
|
||||
aliasIndex?: IngredientAliasIndex
|
||||
) {
|
||||
// With no alias index, this resolves to a plain lowercase compare —
|
||||
// same behavior as before aliases existed.
|
||||
const keyOf = (name: string) => (aliasIndex ? resolveIngredientKey(name, aliasIndex) : name.trim().toLowerCase());
|
||||
// With no alias index, this resolves to a plain case/accent-insensitive
|
||||
// compare — same behavior as before aliases existed, just accent-aware.
|
||||
const keyOf = (name: string) =>
|
||||
aliasIndex ? resolveIngredientKey(name, aliasIndex) : name.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||
|
||||
const pantryKeys = new Set(pantry.map((p) => keyOf(p.rawName)));
|
||||
const expiringSoonKeys = new Set(
|
||||
|
||||
@@ -37,7 +37,10 @@ export type PantryAdjustedItem = {
|
||||
};
|
||||
|
||||
export function normalizeName(name: string): string {
|
||||
const trimmed = name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
// Accent-insensitive too ("café"/"cafe") — NFD splits an accented letter
|
||||
// into base letter + combining mark, then the marks (U+0300-U+036F) are
|
||||
// stripped.
|
||||
const trimmed = name.toLowerCase().trim().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\s+/g, " ");
|
||||
// Simple plural trim — strip a single trailing "s" for words longer than 3 chars.
|
||||
return trimmed.length > 3 && trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.85.0",
|
||||
"version": "0.86.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user