Skip to content

Commit 4bdb2ed

Browse files
committed
feat: ranges and fractions for ingredients and timers
Resolves: #3 Resolves: #6
1 parent 2f32da8 commit 4bdb2ed

File tree

13 files changed

+1235
-273
lines changed

13 files changed

+1235
-273
lines changed

src/classes/recipe.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import {
2121
flushPendingNote,
2222
findAndUpsertIngredient,
2323
findAndUpsertCookware,
24-
parseNumber,
24+
parseQuantityInput,
2525
extractMetadata,
2626
} from "../parser_helpers";
27+
import { multiplyQuantityValue } from "../units";
2728

2829
/**
2930
* Represents a recipe.
@@ -161,7 +162,9 @@ export class Recipe {
161162
const hidden = modifier === "-";
162163
const reference = modifier === "&";
163164
const isRecipe = modifier === "@";
164-
const quantity = quantityRaw ? parseNumber(quantityRaw) : undefined;
165+
const quantity = quantityRaw
166+
? parseQuantityInput(quantityRaw)
167+
: undefined;
165168

166169
const idxInList = findAndUpsertIngredient(
167170
this.ingredients,
@@ -208,7 +211,7 @@ export class Recipe {
208211
throw new Error("Timer missing units");
209212
}
210213
const name = groups.timerName || undefined;
211-
const duration = parseNumber(durationStr);
214+
const duration = parseQuantityInput(durationStr);
212215
const timerObj: Timer = {
213216
name,
214217
duration,
@@ -275,8 +278,17 @@ export class Recipe {
275278

276279
newRecipe.ingredients = newRecipe.ingredients
277280
.map((ingredient) => {
278-
if (ingredient.quantity && !isNaN(Number(ingredient.quantity))) {
279-
(ingredient.quantity as number) *= factor;
281+
if (
282+
ingredient.quantity &&
283+
!(
284+
ingredient.quantity.type === "fixed" &&
285+
ingredient.quantity.value.type === "text"
286+
)
287+
) {
288+
ingredient.quantity = multiplyQuantityValue(
289+
ingredient.quantity,
290+
factor,
291+
);
280292
}
281293
return ingredient;
282294
})

src/classes/shopping_list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
AddedRecipe,
77
AddedIngredient,
88
} from "../types";
9-
import { addQuantities } from "../units";
9+
import { addQuantities, type Quantity } from "../units";
1010

1111
/**
1212
* Represents a shopping list.
@@ -61,7 +61,7 @@ export class ShoppingList {
6161
try {
6262
if (existingIngredient) {
6363
if (existingIngredient.quantity && ingredient.quantity) {
64-
const newQuantity = addQuantities(
64+
const newQuantity: Quantity = addQuantities(
6565
{
6666
value: existingIngredient.quantity,
6767
unit: existingIngredient.unit ?? "",

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type {
55
Metadata,
66
MetadataExtract,
77
Ingredient,
8+
FixedValue,
9+
Range,
10+
DecimalValue,
11+
FractionValue,
12+
TextValue,
813
Timer,
914
TextItem,
1015
IngredientItem,
@@ -27,6 +32,11 @@ export {
2732
Metadata,
2833
MetadataExtract,
2934
Ingredient,
35+
FixedValue,
36+
Range,
37+
DecimalValue,
38+
FractionValue,
39+
TextValue,
3040
Timer,
3141
TextItem,
3242
IngredientItem,

src/parser_helpers.ts

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1-
import type { MetadataExtract, Metadata } from "./types";
2-
import { metadataRegex } from "./regex";
1+
import type {
2+
MetadataExtract,
3+
Metadata,
4+
FixedValue,
5+
Range,
6+
TextValue,
7+
DecimalValue,
8+
FractionValue,
9+
} from "./types";
10+
import { metadataRegex, rangeRegex, numberLikeRegex } from "./regex";
311
import { Section as SectionObject } from "./classes/section";
412
import type { Ingredient, Note, Step, Cookware } from "./types";
5-
import { addQuantities } from "./units";
13+
import {
14+
addQuantities,
15+
CannotAddTextValueError,
16+
IncompatibleUnitsError,
17+
Quantity,
18+
} from "./units";
619

720
/**
821
* Finds an item in a list or adds it if not present, then returns its index.
@@ -88,15 +101,28 @@ export function findAndUpsertIngredient(
88101
// Ingredient already exists, update it
89102
const existingIngredient = ingredients[index]!;
90103
if (quantity !== undefined) {
91-
const currentQuantity = {
92-
value: existingIngredient.quantity ?? 0,
104+
const currentQuantity: Quantity = {
105+
value: existingIngredient.quantity ?? {
106+
type: "fixed",
107+
value: { type: "decimal", value: 0 },
108+
},
93109
unit: existingIngredient.unit ?? "",
94110
};
95111
const newQuantity = { value: quantity, unit: unit ?? "" };
96112

97-
const total = addQuantities(currentQuantity, newQuantity);
98-
existingIngredient.quantity = total.value;
99-
existingIngredient.unit = total.unit || undefined;
113+
try {
114+
const total = addQuantities(currentQuantity, newQuantity);
115+
existingIngredient.quantity = total.value;
116+
existingIngredient.unit = total.unit || undefined;
117+
} catch (e) {
118+
if (
119+
e instanceof IncompatibleUnitsError ||
120+
e instanceof CannotAddTextValueError
121+
) {
122+
// Addition not possible, so add as a new ingredient.
123+
return ingredients.push(newIngredient) - 1;
124+
}
125+
}
100126
}
101127
return index;
102128
}
@@ -129,18 +155,52 @@ export function findAndUpsertCookware(
129155
return cookware.push(newCookware) - 1;
130156
}
131157

132-
export function parseNumber(input_str: string): number {
133-
const clean_str = String(input_str).replace(",", ".");
134-
if (!clean_str.startsWith("/") && clean_str.includes("/")) {
135-
const [num, den] = clean_str.split("/").map(Number);
136-
return num! / den!;
158+
// Parser when we know the input is either a number-like value
159+
const parseFixedValue = (
160+
input_str: string,
161+
): TextValue | DecimalValue | FractionValue => {
162+
if (!numberLikeRegex.test(input_str)) {
163+
return { type: "text", value: input_str };
137164
}
138-
return Number(clean_str);
165+
166+
// After this we know that s is either a fraction or a decimal value
167+
const s = input_str.trim().replace(",", ".");
168+
169+
// fraction
170+
if (s.includes("/")) {
171+
const parts = s.split("/");
172+
173+
const num = Number(parts[0]);
174+
const den = Number(parts[1]);
175+
176+
return { type: "fraction", num, den };
177+
}
178+
179+
// decimal
180+
return { type: "decimal", value: Number(s) };
181+
};
182+
183+
export function parseQuantityInput(input_str: string): FixedValue | Range {
184+
const clean_str = String(input_str).trim();
185+
186+
if (rangeRegex.test(clean_str)) {
187+
const range_parts = clean_str.split("-");
188+
// As we've tested for it, we know that we have Number-like Quantities to parse
189+
const min = parseFixedValue(range_parts[0]!.trim()) as
190+
| DecimalValue
191+
| FractionValue;
192+
const max = parseFixedValue(range_parts[1]!.trim()) as
193+
| DecimalValue
194+
| FractionValue;
195+
return { type: "range", min, max };
196+
}
197+
198+
return { type: "fixed", value: parseFixedValue(clean_str) };
139199
}
140200

141201
export function parseSimpleMetaVar(content: string, varName: string) {
142202
const varMatch = content.match(
143-
new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m"),
203+
new RegExp(`^${varName}:\s*(.*(?:\r?\n\s+.*)*)+`, "m"),
144204
);
145205
return varMatch
146206
? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ")
@@ -149,7 +209,7 @@ export function parseSimpleMetaVar(content: string, varName: string) {
149209

150210
export function parseScalingMetaVar(content: string, varName: string) {
151211
const varMatch = content.match(
152-
new RegExp(`^${varName}:[\\t ]*(([^,\\n]*),? ?(?:.*)?)`, "m"),
212+
new RegExp(`^${varName}:[\t ]*(([^,\n]*),? ?(?:.*)?)`, "m"),
153213
);
154214
if (!varMatch) return undefined;
155215
if (isNaN(Number(varMatch[2]?.trim()))) {
@@ -161,10 +221,7 @@ export function parseScalingMetaVar(content: string, varName: string) {
161221
export function parseListMetaVar(content: string, varName: string) {
162222
// Handle both inline and YAML-style tags
163223
const listMatch = content.match(
164-
new RegExp(
165-
`^${varName}:\\s*(?:\\[([^\\]]*)\\]|((?:\\r?\\n\\s*-\\s*.+)+))`,
166-
"m",
167-
),
224+
new RegExp(`^${varName}:\s*(?:[([^]]*)]|((?:\r?\n\s*-\s*.+)+))`, "m"),
168225
);
169226
if (!listMatch) return undefined;
170227

src/regex.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,7 @@ const multiwordIngredient = createRegex()
2323
.startGroup()
2424
.literal("{")
2525
.startNamedGroup("mIngredientQuantity")
26-
.unicodeDigit().oneOrMore()
27-
.or()
28-
.startGroup()
29-
.unicodeDigit().oneOrMore()
30-
.startGroup()
31-
.anyOf("\\.,\\/")
32-
.unicodeDigit().oneOrMore()
33-
.endGroup().optional()
34-
.endGroup()
26+
.notAnyOf("}%").oneOrMore()
3527
.endGroup().optional()
3628
.startGroup()
3729
.literal("%")
@@ -61,15 +53,7 @@ const singleWordIngredient = createRegex()
6153
.startGroup()
6254
.literal("{")
6355
.startNamedGroup("sIngredientQuantity")
64-
.unicodeDigit().oneOrMore()
65-
.or()
66-
.startGroup()
67-
.unicodeDigit().oneOrMore()
68-
.startGroup()
69-
.anyOf("\\.,\\/")
70-
.unicodeDigit().oneOrMore()
71-
.endGroup().optional()
72-
.endGroup()
56+
.notAnyOf("}%").oneOrMore()
7357
.endGroup().optional()
7458
.startGroup()
7559
.literal("%")
@@ -186,3 +170,28 @@ export const shoppingListRegex = createRegex()
186170
.endGroup()
187171
.global()
188172
.toRegExp()
173+
174+
export const rangeRegex = createRegex()
175+
.startAnchor()
176+
.digit().oneOrMore()
177+
.startGroup()
178+
.anyOf(".,/").exactly(1)
179+
.digit().oneOrMore()
180+
.endGroup().optional()
181+
.literal("-")
182+
.startGroup()
183+
.anyOf(".,/").exactly(1)
184+
.digit().oneOrMore()
185+
.endGroup().optional()
186+
.endAnchor()
187+
.toRegExp()
188+
189+
export const numberLikeRegex = createRegex()
190+
.startAnchor()
191+
.digit().oneOrMore()
192+
.startGroup()
193+
.anyOf(".,/").exactly(1)
194+
.digit().oneOrMore()
195+
.endGroup().optional()
196+
.endAnchor()
197+
.toRegExp()

src/types.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,56 @@ export interface MetadataExtract {
8585
servings?: number;
8686
}
8787

88+
/**
89+
* Represents a quantity described by text, e.g. "a pinch"
90+
* @category Types
91+
*/
92+
export interface TextValue {
93+
type: "text";
94+
value: string;
95+
}
96+
97+
/**
98+
* Represents a quantity described by a decimal number, e.g. "1.5"
99+
* @category Types
100+
*/
101+
export interface DecimalValue {
102+
type: "decimal";
103+
value: number;
104+
}
105+
106+
/**
107+
* Represents a quantity described by a fraction, e.g. "1/2"
108+
* @category Types
109+
*/
110+
export interface FractionValue {
111+
type: "fraction";
112+
/** The numerator of the fraction */
113+
num: number;
114+
/** The denominator of the fraction */
115+
den: number;
116+
}
117+
118+
/**
119+
* Represents a single, fixed quantity.
120+
* This can be a text, decimal, or fraction.
121+
* @category Types
122+
*/
123+
export interface FixedValue {
124+
type: "fixed";
125+
value: TextValue | DecimalValue | FractionValue;
126+
}
127+
128+
/**
129+
* Represents a range of quantities, e.g. "1-2"
130+
* @category Types
131+
*/
132+
export interface Range {
133+
type: "range";
134+
min: DecimalValue | FractionValue;
135+
max: DecimalValue | FractionValue;
136+
}
137+
88138
/**
89139
* Represents an ingredient in a recipe.
90140
* @category Types
@@ -93,7 +143,7 @@ export interface Ingredient {
93143
/** The name of the ingredient. */
94144
name: string;
95145
/** The quantity of the ingredient. */
96-
quantity?: number | string;
146+
quantity?: FixedValue | Range;
97147
/** The unit of the ingredient. */
98148
unit?: string;
99149
/** The preparation of the ingredient. */
@@ -114,7 +164,7 @@ export interface Timer {
114164
/** The name of the timer. */
115165
name?: string;
116166
/** The duration of the timer. */
117-
duration: number;
167+
duration: FixedValue | Range;
118168
/** The unit of the timer. */
119169
unit: string;
120170
}
@@ -140,7 +190,7 @@ export interface IngredientItem {
140190
/** The value of the item. */
141191
value: number;
142192
/** If this is a referenced ingredient, quantity specific to this instance */
143-
partialQuantity?: number | string;
193+
partialQuantity?: FixedValue | Range;
144194
/** If this is a referenced ingredient, unit specific to this instance */
145195
partialUnit?: string;
146196
/** If this is a referenced ingredient, preparation specific to this instance */

0 commit comments

Comments
 (0)