nice clean recipes
pear.dunkirk.sh
recipes
1package cooklang
2
3import (
4 "fmt"
5 "regexp"
6 "sort"
7 "strings"
8
9 "tangled.org/dunkirk.sh/pear/internal/models"
10)
11
12func Export(recipe *models.Recipe) string {
13 var sb strings.Builder
14
15 sb.WriteString("---\n")
16 sb.WriteString(fmt.Sprintf("source: %s\n", recipe.SourceURL))
17 if recipe.Yield != "" {
18 sb.WriteString(fmt.Sprintf("servings: %s\n", recipe.Yield))
19 }
20 if recipe.PrepTime != "" {
21 sb.WriteString(fmt.Sprintf("prepTime: %s\n", formatDuration(recipe.PrepTime)))
22 }
23 if recipe.CookTime != "" {
24 sb.WriteString(fmt.Sprintf("cookTime: %s\n", formatDuration(recipe.CookTime)))
25 }
26 sb.WriteString("---\n\n")
27
28 ingredientIndex := buildIngredientIndex(recipe.Ingredients)
29 matched := make(map[string]bool)
30
31 if len(recipe.Instructions) > 0 {
32 for i, step := range recipe.Instructions {
33 annotated, stepMatched := annotateStep(step.Text, ingredientIndex)
34 for k := range stepMatched {
35 matched[k] = true
36 }
37 sb.WriteString(annotated)
38 if i < len(recipe.Instructions)-1 {
39 sb.WriteString("\n\n")
40 }
41 }
42 }
43
44 var unmatched []models.Ingredient
45 for _, ing := range recipe.Ingredients {
46 key := ingredientKey(ing)
47 if !matched[key] {
48 unmatched = append(unmatched, ing)
49 }
50 }
51
52 if len(unmatched) > 0 {
53 groups := groupIngredients(unmatched)
54 for _, g := range groups {
55 sb.WriteString("\n\n== ")
56 if g.Name != "" {
57 sb.WriteString(g.Name + " ")
58 }
59 sb.WriteString("Ingredients ==\n")
60 for _, ing := range g.Items {
61 ref := ingredientCookRef(ing)
62 sb.WriteString(fmt.Sprintf("- %s\n", ref))
63 }
64 }
65 }
66
67 return sb.String()
68}
69
70type ingredientGroup struct {
71 Name string
72 Items []models.Ingredient
73}
74
75func groupIngredients(ings []models.Ingredient) []ingredientGroup {
76 var groups []ingredientGroup
77 var current *ingredientGroup
78 for _, ing := range ings {
79 if current == nil || ing.Group != current.Name {
80 groups = append(groups, ingredientGroup{Name: ing.Group})
81 current = &groups[len(groups)-1]
82 }
83 current.Items = append(current.Items, ing)
84 }
85 return groups
86}
87
88var timeRangeExportRe = regexp.MustCompile(`(?i)\b(\d+-\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`)
89var timeRe = regexp.MustCompile(`(?i)(^|[^0-9-])(\d+)\s*(seconds?|minutes?|mins?|hours?|hrs?|h)\b`)
90
91var frTimeRangeExportRe = regexp.MustCompile(`(?i)\b(\d+-\d+)\s*(secondes?|minutes?|mins?|heures?|h)\b`)
92var frTimeRe = regexp.MustCompile(`(?i)(^|[^0-9-])(\d+)\s*(secondes?|minutes?|mins?|heures?|h)\b`)
93
94func AnnotateTimersOnly(text string, lang string) string {
95 rangeRe, timeReLang := timeRangeExportRe, timeRe
96 if strings.HasPrefix(lang, "fr") {
97 rangeRe, timeReLang = frTimeRangeExportRe, frTimeRe
98 }
99
100 annotated := rangeRe.ReplaceAllStringFunc(text, func(matchStr string) string {
101 parts := rangeRe.FindStringSubmatch(matchStr)
102 if len(parts) >= 3 {
103 qty := parts[1]
104 unit := parts[2]
105 unit = normalizeTimeUnit(unit)
106 return fmt.Sprintf("~{%s%%%s}", qty, unit)
107 }
108 return matchStr
109 })
110
111 annotated = timeReLang.ReplaceAllStringFunc(annotated, func(matchStr string) string {
112 parts := timeReLang.FindStringSubmatch(matchStr)
113 if len(parts) >= 4 {
114 leading := parts[1]
115 qty := parts[2]
116 unit := parts[3]
117 unit = normalizeTimeUnit(unit)
118 return leading + fmt.Sprintf("~{%s%%%s}", qty, unit)
119 }
120 return matchStr
121 })
122
123 return annotated
124}
125
126func AnnotateStepForDisplay(text string, ingredients []models.Ingredient) string {
127 index := buildIngredientIndex(ingredients)
128 annotated, _ := annotateStep(text, index)
129 return annotated
130}
131
132func annotateStep(text string, ingredients map[string]models.Ingredient) (string, map[string]bool) {
133 annotated := text
134 matched := make(map[string]bool)
135
136 type match struct {
137 start int
138 end int
139 repl string
140 key string
141 }
142
143 var matches []match
144
145 for key, ing := range ingredients {
146 searchNames := searchNamesFor(key)
147 for _, searchName := range searchNames {
148 lowerText := strings.ToLower(annotated)
149 lowerSearch := strings.ToLower(searchName)
150
151 idx := 0
152 for {
153 pos := strings.Index(lowerText[idx:], lowerSearch)
154 if pos < 0 {
155 break
156 }
157 pos += idx
158
159 if isWordBoundary(annotated, pos, pos+len(searchName)) {
160 cookRef := ingredientCookRefInStep(ing, key)
161 matches = append(matches, match{start: pos, end: pos + len(searchName), repl: cookRef, key: key})
162 matched[key] = true
163 break
164 }
165 idx = pos + 1
166 }
167 if matched[key] {
168 break
169 }
170 }
171 }
172
173 sort.Slice(matches, func(i, j int) bool {
174 return matches[i].start > matches[j].start
175 })
176
177 for _, m := range matches {
178 annotated = annotated[:m.start] + m.repl + annotated[m.end:]
179 }
180
181 annotated = timeRangeExportRe.ReplaceAllStringFunc(annotated, func(matchStr string) string {
182 parts := timeRangeExportRe.FindStringSubmatch(matchStr)
183 if len(parts) >= 3 {
184 qty := parts[1]
185 unit := parts[2]
186 unit = normalizeTimeUnit(unit)
187 return fmt.Sprintf("~{%s%%%s}", qty, unit)
188 }
189 return matchStr
190 })
191
192 annotated = timeRe.ReplaceAllStringFunc(annotated, func(matchStr string) string {
193 parts := timeRe.FindStringSubmatch(matchStr)
194 if len(parts) >= 4 {
195 leading := parts[1]
196 qty := parts[2]
197 unit := parts[3]
198 unit = normalizeTimeUnit(unit)
199 return leading + fmt.Sprintf("~{%s%%%s}", qty, unit)
200 }
201 return matchStr
202 })
203
204 return annotated, matched
205}
206
207func normalizeTimeUnit(unit string) string {
208 unit = strings.ToLower(unit)
209 switch unit {
210 case "sec", "secs":
211 return "second"
212 case "min", "mins":
213 return "minute"
214 case "hr", "hrs", "h":
215 return "hour"
216 case "seconde", "secondes":
217 return "second"
218 case "heure", "heures":
219 return "hour"
220 default:
221 return unit
222 }
223}
224
225func isWordBoundary(s string, start, end int) bool {
226 if start > 0 {
227 c := s[start-1]
228 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
229 return false
230 }
231 }
232 if end < len(s) {
233 c := s[end]
234 if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
235 return false
236 }
237 }
238 return true
239}
240
241func searchNamesFor(key string) []string {
242 names := []string{key}
243
244 stripPrefixes := []string{"ground ", "dried ", "fresh ", "frozen ", "canned ", "cooked ", "raw ", "minced ", "chopped ", "crushed ", "grated ", "sliced ", "diced ", "powdered ", "granulated "}
245 lower := strings.ToLower(key)
246 for _, prefix := range stripPrefixes {
247 if strings.HasPrefix(lower, prefix) {
248 short := key[len(prefix):]
249 names = append(names, short)
250 }
251 }
252
253 return names
254}
255
256func ingredientKey(ing models.Ingredient) string {
257 if ing.Name != "" {
258 return ing.Name
259 }
260 return extractIngredientName(ing.RawText)
261}
262
263func ingredientCookRef(ing models.Ingredient) string {
264 name := ingredientKey(ing)
265 needsBraces := strings.Contains(name, " ")
266
267 if ing.Quantity != "" && ing.Unit != "" {
268 if needsBraces {
269 return fmt.Sprintf("@%s{%s%%%s}", name, ing.Quantity, ing.Unit)
270 }
271 return fmt.Sprintf("@%s{%s%%%s}", name, ing.Quantity, ing.Unit)
272 }
273 if ing.Quantity != "" {
274 if needsBraces {
275 return fmt.Sprintf("@%s{%s}", name, ing.Quantity)
276 }
277 return fmt.Sprintf("@%s{%s}", name, ing.Quantity)
278 }
279 if needsBraces {
280 return fmt.Sprintf("@%s{}", name)
281 }
282 return fmt.Sprintf("@%s", name)
283}
284
285func ingredientCookRefInStep(ing models.Ingredient, key string) string {
286 needsBraces := strings.Contains(key, " ")
287
288 if ing.Quantity != "" && ing.Unit != "" {
289 if needsBraces {
290 return fmt.Sprintf("@%s{%s%%%s}", key, ing.Quantity, ing.Unit)
291 }
292 return fmt.Sprintf("@%s{%s%%%s}", key, ing.Quantity, ing.Unit)
293 }
294 if ing.Quantity != "" {
295 if needsBraces {
296 return fmt.Sprintf("@%s{%s}", key, ing.Quantity)
297 }
298 return fmt.Sprintf("@%s{%s}", key, ing.Quantity)
299 }
300 if needsBraces {
301 return fmt.Sprintf("@%s{}", key)
302 }
303 return fmt.Sprintf("@%s", key)
304}
305
306func buildIngredientIndex(ingredients []models.Ingredient) map[string]models.Ingredient {
307 index := make(map[string]models.Ingredient)
308 for _, ing := range ingredients {
309 key := ingredientKey(ing)
310 if key != "" {
311 index[key] = ing
312 }
313 }
314 return index
315}
316
317var ingredientPrefixRe = regexp.MustCompile(`^(?i)(\d+\s*\d*/?\d*\s+)?(?:of\s+)?(.+)$`)
318
319func extractIngredientName(raw string) string {
320 raw = strings.TrimSpace(raw)
321 m := ingredientPrefixRe.FindStringSubmatch(raw)
322 if len(m) >= 3 && m[2] != "" {
323 name := m[2]
324 name = strings.TrimPrefix(name, "of ")
325 name = strings.TrimSpace(name)
326 name = strings.TrimSuffix(name, ",")
327 name = strings.TrimSpace(name)
328 return name
329 }
330 return raw
331}
332
333func formatDuration(iso string) string {
334 if strings.HasPrefix(iso, "PT") {
335 d := strings.TrimPrefix(iso, "PT")
336 d = strings.Replace(d, "H", " hours", 1)
337 d = strings.Replace(d, "M", " minutes", 1)
338 d = strings.Replace(d, "S", " seconds", 1)
339 return strings.TrimSpace(d)
340 }
341 return iso
342}