nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 342 lines 8.6 kB view raw
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}