わが家の脱・料理属人化プロジェクト、基本機能編の 3 回目です。
前回は、コツやアレンジを残す 3 つの置き場所(メモ・作った記録・変更履歴)と、その Firestore の設計を紹介しました。
ただ、置き場所を用意しても、入力が面倒では記録が続きません。
今回は、レシピの手入力を楽にする工夫を紹介します。
- 導入編はこちら → わが家の料理属人化を解消したくて、家族専用のレシピアプリを作った(導入編)
- アプリ全体像はこちら → 家族のレシピアプリの全体像 - Firebase と Cloudflare Workers でコスト 0 で運用(基本機能編 1)
- 前回はこちら → 家族のレシピアプリ - コツを忘れないための Firestore 設計(基本機能編 2)
困っていたこと
残っているレシピがあっても、スマホのメモやメッセージにテキストで書いたものがほとんどでした。
それを材料 1 つずつ、名前の欄と分量の欄に分けて入れ直すのはかなりの手間です。
登録が面倒なままだと、そのうち誰もレシピを登録しなくなってしまいます。
そこで、レシピの登録では手間を減らすことを何より優先し、次の 2 つを決めました。
- 必須の項目は料理名だけにし、書きかけの状態でも保存できる
- 材料と手順は、複数行をまとめて貼り付けられる
材料の 1 行を、名前と分量に分ける
貼り付けた材料は、1 行ずつ「名前」と「分量」に分けます。
分け方は次の 3 段階です。
- 区切りの記号(「……」「・・・」「:」)があれば、そこで分ける
- 行末に分量らしき文字があれば、空白がなくても分ける(「塩少々」「豚肉200g」)
- どちらもなければ、最後の空白で分ける。空白もなければ全体を名前にする
テストのケースからいくつか抜き出すと、次のように分かれます。
| 入力 | 名前 | 分量 |
|---|---|---|
| 砂糖……大さじ1 | 砂糖 | 大さじ1 |
| サラダ油 大さじ 1 | サラダ油 | 大さじ 1 |
| 塩少々 | 塩 | 少々 |
| 豚肉200g | 豚肉 | 200g |
| 牛乳200ml | 牛乳 | 200ml |
| 砂糖大さじ1と1/2 | 砂糖 | 大さじ1と1/2 |
| なす中2本 | なす | 中2本 |
| 鶏もも肉1枚(300g) | 鶏もも肉 | 1枚(300g) |
| ねぎお好みで | ねぎ | お好みで |
| 7種のスパイス | 7種のスパイス | (なし) |
| 3色そぼろ | 3色そぼろ | (なし) |
「分量らしき文字」を正規表現で組み立てる
行末の分量は、部品を組み合わせて作った正規表現で探します。
// 分量らしき文字の部品。長いものを先に並べる(「お好みで」を「お好み」より先に、など)
const NUMBER = '(?:[0-90-9]+(?:[..//][0-90-9]+)?|半)';
// 1〜2、1と1/2
const NUMBER_RANGE = `${NUMBER}(?:\\s*(?:[〜~~\\--]|と)\\s*${NUMBER})?`;
const MEASURES = ['計量カップ', '大さじ', '小さじ', '大匙', '小匙', 'カップ'].join('|');
// 長い単位を先に並べる(「切れ」を「切」より先に、など)
const UNITS = [
'キログラム グラム リットル センチ パック カップ 切れ かけ',
'kg mg ml mL cc cm kg ml cc cm g g L ℓ',
'個 本 枚 片 株 束 袋 缶 尾 匹 玉 房 丁 合 杯 粒 膳 箱 節 把 切',
]
.join(' ')
.replace(/ /g, '|');
const WORDS = ['お好みで', 'お好み', 'ひとつまみ', 'ひとつかみ', 'たっぷり', '少々', '少量', '適量', '適宜', '少し'].join('|');
// 「約」「各」、大きさの「大」「中」「小」
const PREFIX = '(?:約|各)?\\s*(?:[大中小](?!さじ|匙))?';
// 「強」「弱」「程度」などと、後ろの括弧(「200g(1枚)」)
const SUFFIX = '(?:分|強|弱|程度|くらい|ぐらい|ほど)?(?:\\s*[((][^))]*[))])?';
/** 行末にある分量。空白をまたいでもよい(「大さじ 1」) */
const AMOUNT_AT_END = new RegExp(`(${PREFIX}(?:(?:${MEASURES})\\s*${NUMBER_RANGE}|${NUMBER_RANGE}\\s*(?:${UNITS})?|${WORDS})${SUFFIX})$`);
工夫したのは次の 5 点です。
- 数字は全角も半角も拾う(
[0-90-9])
→ 手書きのメモを打ち直すと、全角の数字が混ざりやすいため - 「半」も数字として扱う
→ 「玉ねぎ半分」の「半」を分量として拾うため - 大きさの「大」「中」「小」の後ろに、否定先読み
(?!さじ|匙)を付ける
→ 「なす中2本」の「中」は拾いつつ、「大さじ」の「大」を大きさと読まないため - 選択肢は長いものを先に並べる(「お好みで」と「お好み」、「切れ」と「切」)
→ 順番をそろえておくと、後で読む人(未来の自分と AI)が迷わないため - 後ろの括弧まで分量に含める
→ 「鶏もも肉1枚(300g)」の「(300g)」が名前に残らないようにするため
この正規表現を使って、3 段階の分け方を書いています。
/** 名前と分量の間の区切りの記号(「砂糖……大さじ1」「砂糖:大さじ1」) */
const SEPARATOR = /^(.*?\S)\s*(?:…+|‥+|・{2,}|\.{3,}|[::])\s*(\S.*)$/;
/**
* 材料の 1 行を名前と分量に分ける。
* 1. 区切りの記号があれば、そこで分ける
* 2. 行末に分量らしき文字があれば、空白がなくても分ける(「塩少々」「豚肉200g」)
* 3. どちらもなければ、最後の空白(全角の空白・タブを含む)で分ける。空白もなければ全体を名前にする
*/
export function splitIngredientLine(line: string): PastedIngredient {
const trimmed = line.trim();
const separated = SEPARATOR.exec(trimmed);
if (separated?.[1] && separated[2]) {
return { name: separated[1], amount: separated[2] };
}
const amount = AMOUNT_AT_END.exec(trimmed);
if (amount?.[1]) {
const name = trimmed.slice(0, amount.index).trim();
if (name !== '') {
return { name, amount: amount[1].trim() };
}
}
const match = /^(.*\S)\s+(\S+)$/.exec(trimmed);
if (!match) {
return { name: trimmed, amount: null };
}
return { name: match[1] ?? trimmed, amount: match[2] ?? null };
}
区切りの記号の「・」は、2 つ以上続くときだけ区切りとして扱います。
「塩・こしょう」の「・」で分けてしまわないようにするためです。
手順の番号は捨てる
手順は、行の先頭にある番号を取り除いてから 1 行ずつ入れます。
番号はアプリが振り直すため、貼り付けた文章の番号は不要です。
const DIGITS = '[0-90-9]+';
const STEP_NUMBER_PATTERNS = [
// STEP1、Step 2.、ステップ3:、手順4
new RegExp(`^(?:step|ステップ|手順)\\s*${DIGITS}\\s*[..))、,,::]?\\s*`, 'i'),
// (1)、(2)、【3】、[4]
new RegExp(`^[((【[\\[]\\s*${DIGITS}\\s*[))】]\\]]\\s*`),
// 1.、2)、3.、4、(小数の「1.5」は除く)
new RegExp(`^${DIGITS}\\s*[..))、,,::](?![0-90-9])\\s*`),
// ①〜⑳、㉑〜㊿
/^[①-⑳㉑-㉟㊱-㊿]\s*/,
// (一部を省略)
];
「1.5倍の水を入れる」のような手順の「1.」を番号と読まないよう、後ろに数字が続くときは番号として扱いません。
丸数字は ㉑〜㊿ も対象にしています。50 を超える手順のレシピはまずないため、ここまであれば十分だと考えています。
画面から切り離して、テストする
こうした処理は、画面に依存しない共有のパッケージ(packages/core)に置き、bun test で単体テストしています。
最初に載せた表も、テストの test.each から抜き出したものです。
test.each([
['塩少々', '塩', '少々'],
['豚肉200g', '豚肉', '200g'],
['砂糖大さじ1と1/2', '砂糖', '大さじ1と1/2'],
['鶏もも肉1枚(300g)', '鶏もも肉', '1枚(300g)'],
// …
])('空白がなくても、行末の分量で分ける「%s」→ 名前「%s」・分量「%s」', (line, name, amount) => {
expect(splitIngredientLine(line)).toEqual({ name, amount });
});
正規表現は、1 か所直すと別のケースに影響することがよくあります。
そのため、家族から「この書き方だと分かれない」と言われたら、まずテストに 1 行足してから直すようにしています。
運用してから足したもの
分量の入力を楽にする「キーボードの真上の入力補助バー」を足しました。
こちらは運用編で紹介する予定です。
まとめ
必須の項目を料理名だけにし、材料と手順は貼り付けでまとめて入れられるようにしました。
材料は「区切りの記号 → 行末の分量 → 最後の空白」の 3 段階で名前と分量に分けています。
正規表現は部品に分けて画面から切り離し、家族の書き方を test.each に 1 つずつ足しながら育てています。
次回は、登録したレシピを探す検索の話を予定しています。
