144 lines
4.9 KiB
Dart
144 lines
4.9 KiB
Dart
/// A single ingredient line from a recipe with best-effort structured
|
|
/// parsing of quantity, unit, and item name.
|
|
///
|
|
/// Pure Dart (no framework imports).
|
|
class RecipeIngredient {
|
|
const RecipeIngredient({
|
|
required this.rawText,
|
|
required this.parsedItemName,
|
|
this.quantity,
|
|
this.unit,
|
|
});
|
|
|
|
/// Unit vocabulary recognized during parsing (lowercase). Anything else
|
|
/// stays part of [parsedItemName] (e.g. "1 large egg" keeps "large egg").
|
|
static const Set<String> _knownUnits = {
|
|
'cup', 'cups',
|
|
'tbsp', 'tbsps', 'tablespoon', 'tablespoons', 'tablespoonful', 'tablespoonfuls',
|
|
'tsp', 'tsps', 'teaspoon', 'teaspoons', 'teaspoonful', 'teaspoonfuls',
|
|
'ounce', 'ounces', 'oz',
|
|
'pound', 'pounds', 'lb', 'lbs',
|
|
'gram', 'grams', 'g',
|
|
'kilogram', 'kilograms', 'kg',
|
|
'milligram', 'milligrams', 'mg',
|
|
'milliliter', 'milliliters', 'millilitre', 'millilitres', 'ml',
|
|
'liter', 'liters', 'litre', 'litres', 'l',
|
|
'pinch', 'pinches', 'dash', 'dashes',
|
|
'clove', 'cloves', 'slice', 'slices', 'piece', 'pieces',
|
|
'can', 'cans', 'bottle', 'bottles', 'jar', 'jars',
|
|
'package', 'packages', 'pack', 'bag', 'bags',
|
|
'bunch', 'bunches', 'sprig', 'sprigs',
|
|
'head', 'heads', 'stick', 'sticks', 'cube', 'cubes',
|
|
'dozen', 'bowl', 'bowls', 'handful', 'handfuls',
|
|
'serving', 'servings', 'rib', 'ribs', 'stalk', 'stalks',
|
|
};
|
|
|
|
static final RegExp _numberToken = RegExp(r'^\d+(?:\.\d+)?$');
|
|
static final RegExp _fractionToken = RegExp(r'^(\d+)/(\d+)$');
|
|
|
|
/// The original (trimmed) ingredient line exactly as published.
|
|
final String rawText;
|
|
|
|
/// Remaining text after quantity/unit extraction, e.g. `chicken breasts`.
|
|
final String parsedItemName;
|
|
|
|
/// Leading numeric quantity (`1.5`, `0.25`, mixed `1 1/2` → `1.5`).
|
|
final double? quantity;
|
|
|
|
/// Lowercased unit word when recognized (e.g. `cup`, `tsp`).
|
|
final String? unit;
|
|
|
|
/// Splits [rawText] into quantity / unit / item name.
|
|
///
|
|
/// Handles decimals (`2.5`), ASCII fractions (`1/2`), mixed numbers
|
|
/// (`1 1/2 cups`), and unicode fractions (`1½`), and only consumes a unit
|
|
/// token when a leading quantity was present and the token belongs to
|
|
/// [_knownUnits].
|
|
factory RecipeIngredient.parse(String rawText) {
|
|
// Normalize unicode fractions to plain decimal tokens first, so
|
|
// "1½ cup sugar" becomes "1 0.5 cup sugar" and sums to 1.5.
|
|
var normalized = rawText
|
|
.trim()
|
|
.replaceAll('¼', ' 0.25 ')
|
|
.replaceAll('½', ' 0.5 ')
|
|
.replaceAll('¾', ' 0.75 ');
|
|
normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
if (normalized.isEmpty) {
|
|
return RecipeIngredient(rawText: rawText.trim(), parsedItemName: '');
|
|
}
|
|
|
|
final tokens = normalized.split(' ');
|
|
var index = 0;
|
|
double? quantity;
|
|
|
|
if (_numberToken.hasMatch(tokens[index])) {
|
|
quantity = double.parse(tokens[index]);
|
|
index++;
|
|
// Mixed number: "1 1/2" — or the normalized "1 0.5" of "1½".
|
|
if (index < tokens.length) {
|
|
final fraction = _fractionToken.firstMatch(tokens[index]);
|
|
if (fraction != null) {
|
|
quantity += _fractionValue(fraction);
|
|
index++;
|
|
} else if (_numberToken.hasMatch(tokens[index]) && quantity == quantity.roundToDouble()) {
|
|
quantity += double.parse(tokens[index]);
|
|
index++;
|
|
}
|
|
}
|
|
} else {
|
|
final fraction = _fractionToken.firstMatch(tokens[index]);
|
|
if (fraction != null) {
|
|
quantity = _fractionValue(fraction);
|
|
index++;
|
|
}
|
|
}
|
|
|
|
String? unit;
|
|
if (quantity != null && index < tokens.length) {
|
|
final candidate = tokens[index].toLowerCase();
|
|
if (_knownUnits.contains(candidate)) {
|
|
unit = candidate;
|
|
index++;
|
|
}
|
|
}
|
|
|
|
return RecipeIngredient(
|
|
rawText: rawText.trim(),
|
|
parsedItemName: tokens.sublist(index).join(' '),
|
|
quantity: quantity,
|
|
unit: unit,
|
|
);
|
|
}
|
|
|
|
static double _fractionValue(Match match) {
|
|
final numerator = double.tryParse(match.group(1) ?? '') ?? 0;
|
|
final denominator = double.tryParse(match.group(2) ?? '') ?? 1;
|
|
return denominator == 0 ? 0 : numerator / denominator;
|
|
}
|
|
|
|
/// Serialization helper for the `rawIngredientsJson` persistence column.
|
|
Map<String, Object?> toMap() => <String, Object?>{
|
|
'rawText': rawText,
|
|
'parsedItemName': parsedItemName,
|
|
'quantity': quantity,
|
|
'unit': unit,
|
|
};
|
|
|
|
/// Deserializes a map produced by [toMap]; malformed entries fall back to
|
|
/// the raw text only.
|
|
factory RecipeIngredient.fromMap(Map<String, Object?> map) {
|
|
final rawText = map['rawText']?.toString() ?? '';
|
|
return RecipeIngredient(
|
|
rawText: rawText,
|
|
parsedItemName: map['parsedItemName']?.toString() ?? rawText,
|
|
quantity: (map['quantity'] as num?)?.toDouble(),
|
|
unit: map['unit']?.toString(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
String toString() =>
|
|
'RecipeIngredient($rawText → qty $quantity ${unit ?? ''} '
|
|
'$parsedItemName)';
|
|
}
|