/// Shared English-singularization used to fold plural ingredient and unit /// strings onto one canonical token (`eggs` → `egg`, `lbs` → `lb`, /// `chicken breasts` → `chicken breast`). /// /// Rules are intentionally conservative and applied identically to both sides /// of a comparison (recipe line vs. pantry row), so even imperfect results /// still match as long as they are consistent. String singularizeWord(String word) { if (word.length <= 2) return word; // berries → berry. if (word.endsWith('ies') && word.length > 4) { return '${word.substring(0, word.length - 3)}y'; } // class / glass — never strip a double s. if (word.endsWith('ss')) return word; // bunches → bunch, boxes → box, buses → bus, tomatoes → tomato. if (word.endsWith('ches') || word.endsWith('shes') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ses') || word.endsWith('oes')) { return word.substring(0, word.length - 2); } // status / asparagus keep their s. if (word.endsWith('us') || word.endsWith('is')) return word; // eggs → egg. if (word.endsWith('s')) return word.substring(0, word.length - 1); return word; } /// Canonical, lowercase ingredient key used for fuzzy pantry ↔ recipe /// matching (e.g. `Chicken Breast (boneless)` → `chicken breast`). /// /// The pipeline: trim/lowercase → drop parenthetical qualifiers → collapse /// punctuation to spaces → singularize each token. String canonicalIngredientName(String raw) { var text = raw.trim().toLowerCase(); if (text.isEmpty) return ''; // Drop parenthetical qualifiers: "chicken breast (boneless)" → "chicken breast". text = text.replaceAll(RegExp(r'\([^)]*\)'), ' '); // Collapse punctuation so "chicken-breast" and "chicken, breast" still // match the plain "chicken breast" pantry key. text = text.replaceAll(RegExp(r'[^a-z0-9]+'), ' '); text = text.replaceAll(RegExp(r'\s+'), ' ').trim(); if (text.isEmpty) return ''; return text.split(' ').map(singularizeWord).join(' '); }