Kerfuffle is the recipe app I built because I wanted my recipes on my phone, and nowhere else. No account, no server, no ads, no analytics. I built it solo, in Flutter. With no server to call, a shared recipe or a typed shopping-list line has to be parsed on the phone.
The app speaks English, Spanish, Japanese, Kazakh, Korean, and Russian. A shopping list for the week has to be able to merge 2 cups flour with 2 стакана муки and 밀가루 2컵 into one line for flour. A model on a server would have broken the offline rule. An on-device model felt like a lot of machinery for a line that is usually just a number, a unit, and a name, written in a handful of word orders.
So the parser is rule-based. It is a pure function: one raw line in, a quantity, a display string, a unit, a name, and an optional note out. It runs offline on every import line. And when a line is ambiguous, it prefers no quantity over a wrong guess. Imports land as a draft you review before saving, so a blank quantity is easy to fix. A confidently wrong one is not.
Four grammars, first match wins
There is no separate tokenizer, and the parser does not take a language code. One combined table covers all six languages. Every line goes through the same steps.
First the line is normalized. Non-breaking spaces and tabs become ordinary spaces, a leading bullet is stripped, and extra whitespace is collapsed. Then notes are peeled off before any grammar runs: parentheses, including full-width (), and "to taste"-style phrases in all six languages — to taste, al gusto, по вкусу, дәміне қарай, 약간 / 적당량, 適量 / 少々, and a few synonyms.
What remains is tried against four grammars, in order. The first match wins:
/// Grammar, tried in this order:
/// 1. `[qty] [unit] [of] name [, note]` — en / ru / kk, and "2컵 밀가루"
/// 2. `пол(-)unit name` — Russian "полстакана"
/// 3. `unit [of] name` (quantity = 1) — "pinch of salt", "щепотка соли"
/// 4. `name [—-:] qty [unit] [note]` — Korean default, Russian sites
/// 5. name only.
/// Parenthesised text and "to taste"-style phrases are pulled into `note`
/// before any of that runs.
final result = _parseLeading(s) ??
_parsePolPrefix(s) ??
_parseUnitFirst(s) ??
_parseTrailing(s);
Leading quantity covers English and Spanish (2 cups flour, 2 tazas de harina), Russian and Kazakh, and Korean when the amount comes first (2컵 밀가루). If dozen follows the number, the quantity becomes twelve and the unit is cleared, so 1 dozen eggs is 12 eggs, not one dozen.
The Russian пол- prefix is its own rule, because полстакана молока and пол чайной ложки соли mean half, and that does not belong in the generic number-word table. Полба, the grain, is explicitly not "half of ба".
Unit-first lines have no number. pinch of salt and щепотка соли both become quantity 1.
The trailing rule is the default for Korean (밀가루 2컵) and is common on Russian and Kazakh sites (Мука — 200 г, Ұн — 2 стакан). Japanese spoons write the unit before the number (醤油 大さじ3), so after the trailing parser finds a quantity it looks up to eight characters back for a unit that ends exactly there.
If nothing matches, the cleaned line is the name, and quantity and unit stay empty. That is how Salt to taste, 소금 약간, and 塩 適量 are stored: a name and a note, no invented amount.
Units themselves come from a pattern table. At a given position the longest match wins, so ст. л. beats ст. and 큰술 beats 큰. Case-sensitive single-letter spoons (1 T for a tablespoon, 1 t for a teaspoon) sit outside the table and only count immediately after a quantity, so T-bone steak is not a tablespoon of steak.
A cup is not always 240 ml
Regional cups are different units, not one unit with a locale flag, because conversion needs a different millilitre value for each:
'cup': UnitDef('cup', UnitKind.volume, UnitSystem.us, 240),
'cup_ru': UnitDef('cup_ru', UnitKind.volume, UnitSystem.ru, 250),
'cup_ko': UnitDef('cup_ko', UnitKind.volume, UnitSystem.ko, 200),
'cup_ja': UnitDef('cup_ja', UnitKind.volume, UnitSystem.ja, 200),
'go': UnitDef('go', UnitKind.volume, UnitSystem.ja, 180),
A US cup is 240 ml. A Russian or Kazakh стакан / стақан is 250 ml. A Korean 컵 and a Japanese カップ are 200 ml. Japanese 合, used for rice, is 180 ml. Korean 근 is a mass unit, 600 g. Tablespoons and teaspoons are 15 ml and 5 ml everywhere.
Volume and mass convert through those bases. Count units never do: pieces, cloves, pinches, cans, bunches, and the rest stay as counts. If a conversion is refused, the original value and unit are left alone.
Fractions, decimal commas, and ranges
Numeric amounts are matched longest-first, so 1 1/2 is not read as 1. Mixed numbers work with and, Russian и, Kazakh және, and Korean 과 (양파 1과 1/2개). Unicode fractions live in a lookup table — ½, ¼, 1½, and the rest of the usual set — and so do ASCII fractions like 1/2.
Decimals accept a dot or a comma: 1.5, 0,5, 1,5 кг. The comma form is what Russian and Kazakh recipes use.
Ranges keep the original text in the display field and use the lower bound as the numeric quantity. Separators are -, –, —, ~, and the words to, до, или, немесе, and 또는. 2-3, 2 to 3, 10–12, and 청양고추 1~2개 all parse that way.
A percent sign after a number is rejected on purpose. 2% milk and Молоко 3,2% stay names. 1 cup 2% milk keeps 2% milk as the name. Сметана 20% — 200 г keeps the fat percentage in the name and takes 200 g as the amount.
The lines that actually show up
Most of the work was ordinary recipe phrasing, not theoretically messy input.
In English, 1 (14 oz) can diced tomatoes is quantity 1, unit can, name diced tomatoes, note 14 oz. Multiple notes join: 1 medium onion (about 150 g), diced. Juice of 1 lemon and Zest of one lemon stay whole names; the trailing rule will not split them into a name and a bare number. Bullets are stripped, so - 2 cups flour and • 2 cups flour are the same line.
Russian sites like to put the name first: Мука — 2 стакана, Яйца 3 шт, Лук 1 шт крупный (the note is крупный). Abbreviations such as 0,5 ч.л. соды and ст. л. have to be the longest match, as above. Unit-first also shows up: щепотка соли, пучок укропа.
Kazakh recipes use their own units — ас қасық, шай қасық, келі, тіс, бау, уыс, шымшым — and they also use Russian units freely. 2 стакан ұн is still a 250 ml стакан. Number words work (жарты стакан сүт, екі жұмыртқа), and so does name-first order (Қант 100 г).
Korean defaults to name, then amount and counter, often with no space: 밀가루 2컵, 달걀 2개, 두부 1모, 간장 2큰술, 고기 1근. Native number words attach to the counter (계란 한 개, 양파 반개). 두세 개 마늘 is an approximate range and becomes 2. The lookbehind on the quantity regex only blocks Latin and Cyrillic letters, not Hangul, kana, or kanji, which is why 2컵 and 大さじ3 match at all:
// A digit must not continue a word ("abc3", "тест3"), but Japanese and
// Korean glue the counter straight onto the number ("大さじ3", "2컵"),
// so only Latin and Cyrillic letters block a match.
r'(?<![\d.,/])(?<![A-Za-zЀ-ӿҠ-һ])'
한 is also the word for "one", but 한우 300g has to stay Korean beef plus 300 g, not "one 우" plus 300 g.
Japanese is name then number and counter (小麦粉 2カップ, 米 2合, 豆腐 1/2丁), except for those spoons. Full-width parentheses become notes: 玉ねぎ 1個(薄切り). The "to taste" pattern for 少々 is bounded so it does not swallow こしょう out of 黒こしょう.
Spanish strips de / del the way English strips of, but it cannot stop at the first de. 3 cdas de salsa de soja has to remain soy sauce, not "salsa".
One flour, six names
Parsing is only half of the shopping list. After a line is parsed, IngredientMatcher maps the name to a canonical id and an aisle. The dictionary ships with the app: 144 ingredients, aliases in all six languages, ten aisles. flour, мука, 밀가루, 小麦粉, ұн, and harina share one id. Prep words are stripped (finely chopped onion, cebolla picada, 다진 마늘, おろしにんにく), but a name whose identity includes a prep word is tried exact-first, so whole chicken is not chicken breast, while whole milk still becomes milk. The longest alias wins: sesame oil is not oil. English and Spanish plurals are handled carefully enough that a naïve es strip cannot turn cloves into clov. Russian genitives are dictionary aliases (муки → мука), not stemming. Green onion, scallions, зелёного лука, 파, cebolletas, and 青ねぎ share one id. Unknown names still get a stable lowercase key and land in the "other" aisle, so two copies of the same unknown still merge with each other.
GroceryMerger then folds lines that share a canonical id, with a few hard rules:
static bool canMerge(GroceryLine a, GroceryLine b) {
if (a.canonicalId != b.canonicalId) return false;
// "to taste" lines have no quantity; folding them loses nothing.
if (a.quantity == null || b.quantity == null) return true;
if (a.unit == b.unit) return true;
// A bare count ("2 scallions") and a counted unit ("2 stalks") are the
// same shopping decision; a bare count and a *measured* amount
// ("200 g eggs") are not.
if (a.unit == null) return UnitConverter.isCount(b.unit);
if (b.unit == null) return UnitConverter.isCount(a.unit);
return UnitConverter.canConvert(a.unit, b.unit);
}
Same unit, add the quantities. A "to taste" line folds into a measured one and keeps the known amount. Different measured units merge only when they convert (volume with volume, mass with mass), and the first line's unit wins: 250 ml milk plus 1 cup milk becomes 490 ml. Weight and volume never merge, so 200 g chocolate and 1 cup chocolate stay two lines. Count units never convert into weights, or into each other: 2 cloves garlic and 1 bunch garlic stay two lines. A bare 3 eggs can merge with a count unit, but not with 200 g eggs.
What it does not handle yet
The parser does not normalize full-width digits. 2 is not treated as 2, and there is no NFKC pass, so a line like 小麦粉 2カップ will not get a quantity today. Japanese kanji numerals (一, 二, 半分) and Sino-Korean numbers (일, 이, 삼) are also not handled. Those will need work. I have not pretended they already work.
The parser also does not take a language argument. A mixed or ambiguous line has only the combined tables and the "prefer no quantity" rule to fall back on. The draft you review before saving is there for the rest.
Kerfuffle is free on the App Store, with 132 starter recipes and no account to create. If you want to try it on a real recipe, it is here. The site is at hyorinmaruk.github.io/recipebox-site.
Top comments (0)