Files
Epicure/apps/web/lib/extract-ingredient-quantity.ts
T
Arnaud 5afc7cd182 feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix
- Fixed a real i18n bug: the checked-count line called the wrong
  translation namespace and rendered the literal key on screen
- Shopping lists can now be renamed and deleted from both the list index
  and detail pages (API already supported delete; rename was net new)
- Root-caused "long list UI is off": meal-plan-generated lists never set
  an aisle, so every item fell into one undifferentiated "Other" bucket
  despite the grouping UI existing. Added a keyword-based aisle guesser
  wired into list generation (fallback only, never overrides an explicit
  aisle) plus a one-click "auto-categorize" for existing lists
- Items can now be reordered by drag-and-drop within a category (dnd-kit),
  recategorized via a dropdown, deleted, searched, and sorted (category /
  alphabetical / unchecked-first); searching flattens the grouped view
- Fixed a separate bug: AI-generated ingredients sometimes embedded the
  quantity/unit in the name itself (e.g. "2 cups flour" as one string).
  Added extractIngredientQuantity() as a Zod transform at both recipe
  create/update routes (the choke point every creation path funnels
  through) to split it back out, plus schema descriptions on the AI
  ingredient schemas as a prevention layer

New migration 0028 (shopping_list_items.sort_order), left unapplied like
the others. Verified with typecheck, lint, and a clean --no-cache docker
build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 10:29:28 +02:00

69 lines
3.0 KiB
TypeScript

import { parseQuantity } from "./parse-quantity";
const KNOWN_UNITS = new Set([
"cup", "cups", "c",
"tablespoon", "tablespoons", "tbsp", "tbsps", "tbs",
"teaspoon", "teaspoons", "tsp", "tsps",
"gram", "grams", "g",
"kilogram", "kilograms", "kg",
"ounce", "ounces", "oz",
"pound", "pounds", "lb", "lbs",
"milliliter", "milliliters", "millilitre", "millilitres", "ml",
"liter", "liters", "litre", "litres", "l",
"pinch", "pinches", "dash", "dashes",
"clove", "cloves", "slice", "slices", "piece", "pieces",
"can", "cans", "jar", "jars", "package", "packages", "pkg",
"bunch", "bunches", "sprig", "sprigs", "stick", "sticks",
"quart", "quarts", "qt", "pint", "pints", "pt",
"fl", // matches the first token of "fl oz" — handled below
]);
// Leading numeric token: plain int/decimal, unicode fraction, mixed number ("1 1/2"), or
// simple fraction ("1/2") — mirrors what parseQuantity already knows how to parse.
const LEADING_NUMBER =
/^\s*(\d+\s+\d+\s*\/\s*\d+|\d+\s*\/\s*\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])\s*/;
/**
* Some AI-generated (or pasted/imported) ingredients arrive with the quantity baked into
* the name itself (e.g. rawName: "2 cups flour", quantity/unit left empty) instead of the
* expected separate fields. Detects a leading quantity (+ optional recognized unit) in
* rawName and pulls it out, so the ingredient name shown to the user is just "flour", not
* "2 cups flour". Never runs when an explicit quantity was already provided — an entry
* with real quantity/unit fields is trusted as-is, this is only a fallback for the case
* where the model (or a copy-pasted ingredient line) merged everything into the name.
*/
export function extractIngredientQuantity(
rawName: string,
quantity: string | undefined,
unit: string | undefined
): { rawName: string; quantity: string | undefined; unit: string | undefined } {
if (quantity !== undefined && quantity !== "") return { rawName, quantity, unit };
const match = rawName.match(LEADING_NUMBER);
if (!match) return { rawName, quantity, unit };
const numberToken = match[1]!.trim();
const parsedQuantity = parseQuantity(numberToken);
if (parsedQuantity === undefined) return { rawName, quantity, unit };
let rest = rawName.slice(match[0].length).trim();
if (!rest) return { rawName, quantity, unit }; // nothing left — not actually a name+quantity string
let extractedUnit = unit;
const unitMatch = rest.match(/^([a-zA-Z.]+)\s+(.*)$/);
if (unitMatch && !extractedUnit) {
const candidate = unitMatch[1]!.replace(/\.$/, "").toLowerCase();
if (candidate === "fl" && unitMatch[2]!.toLowerCase().startsWith("oz")) {
extractedUnit = "fl oz";
rest = unitMatch[2]!.replace(/^oz\.?\s*/i, "").trim();
} else if (KNOWN_UNITS.has(candidate)) {
extractedUnit = candidate;
rest = unitMatch[2]!.trim();
}
}
if (!rest) return { rawName, quantity, unit }; // stripping the unit ate the whole string — bail out
return { rawName: rest, quantity: parsedQuantity, unit: extractedUnit };
}