nice clean recipes
pear.dunkirk.sh
recipes
1package main
2
3import (
4 "encoding/json"
5 "flag"
6 "fmt"
7 "html/template"
8 "io/fs"
9 "sync"
10 "log"
11 "net/http"
12 "net/url"
13 "strconv"
14 "os"
15 "strings"
16 "time"
17
18 "github.com/go-chi/chi/v5"
19 "github.com/go-chi/chi/v5/middleware"
20 "github.com/joho/godotenv"
21
22 "tangled.org/dunkirk.sh/pear/internal/cache"
23 "tangled.org/dunkirk.sh/pear/internal/cooklang"
24 "tangled.org/dunkirk.sh/pear/internal/extract"
25 "tangled.org/dunkirk.sh/pear/internal/models"
26 "tangled.org/dunkirk.sh/pear/ui"
27)
28
29var gitHash = "dev"
30
31func main() {
32 godotenv.Load()
33 port := flag.Int("port", 3000, "port to listen on")
34 dbPath := flag.String("db", "pear.db", "path to SQLite database")
35 baseURL := flag.String("base-url", "", "base URL of this service")
36 flag.Parse()
37
38 if *baseURL == "" {
39 *baseURL = os.Getenv("BASE_URL")
40 }
41 if *baseURL == "" {
42 *baseURL = fmt.Sprintf("http://localhost:%d", *port)
43 }
44
45 c, err := cache.New(*dbPath)
46 if err != nil {
47 log.Fatalf("opening cache: %v", err)
48 }
49 defer c.Close()
50
51 tmpl, err := template.New("").Funcs(template.FuncMap{
52 "fmtDuration": fmtDuration,
53 "isoToSeconds": isoToSeconds,
54 "cleanSource": cleanSource,
55 "renderStep": renderStep,
56 "cookHighlight": cookHighlight,
57 "groupIngredients": groupIngredients,
58 "json": func(v string) string { b, _ := json.Marshal(v); return string(b) },
59 "trimProto": func(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") },
60 "shortHash": func(s string) string { if len(s) > 7 { return s[:7] }; return s },
61 }).ParseFS(ui.Templates, "templates/*.html")
62 if err != nil {
63 log.Fatalf("parsing templates: %v", err)
64 }
65
66 srv := &Server{
67 pipeline: extract.NewPipeline(),
68 cache: c,
69 templates: tmpl,
70 baseURL: *baseURL,
71 gitHash: gitHash,
72 pending: make(map[string]chan extractResult),
73 failed: make(map[string]failedEntry),
74 }
75
76 r := chi.NewRouter()
77 r.Use(middleware.Logger)
78 r.Use(middleware.Recoverer)
79 r.Use(middleware.CleanPath)
80
81 staticContent, err := fs.Sub(ui.Static, "static")
82 if err != nil {
83 log.Fatalf("failed to get static fs: %v", err)
84 }
85
86 r.Get("/", srv.handleIndex)
87 r.Get("/cook", srv.handleCookView)
88 r.Get("/export.cook", srv.handleCookExport)
89 r.Get("/recipe", srv.handleRecipeQuery)
90 r.Get("/userscript", srv.handleUserscript)
91 r.Get("/status", srv.handleStatus)
92 r.Get("/flagged", srv.handleFlagged)
93 r.Get("/flag", srv.handleFlag)
94 r.Get("/*", srv.handleRecipePath)
95 r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent))))
96
97 addr := fmt.Sprintf(":%d", *port)
98 log.Printf("http://localhost:%d", *port)
99 if err := http.ListenAndServe(addr, r); err != nil {
100 log.Fatal(err)
101 }
102}
103
104type Server struct {
105 pipeline *extract.Pipeline
106 cache *cache.Cache
107 templates *template.Template
108 baseURL string
109 gitHash string
110 pendingMu sync.Mutex
111 pending map[string]chan extractResult
112 failedMu sync.Mutex
113 failed map[string]failedEntry
114}
115
116type failedEntry struct {
117 msg string
118 failedAt time.Time
119}
120
121type extractResult struct {
122 recipe *models.Recipe
123 err error
124}
125
126type indexRecentRecipe struct {
127 Name string
128 ImageURL string
129 SourceURL string
130 Domain string
131}
132
133func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
134 targetURL := r.URL.Query().Get("url")
135 if targetURL != "" {
136 if strings.HasPrefix(targetURL, "https://") {
137 targetURL = targetURL[8:]
138 } else if strings.HasPrefix(targetURL, "http://") {
139 targetURL = targetURL[7:]
140 }
141 http.Redirect(w, r, "/"+targetURL, http.StatusFound)
142 return
143 }
144
145 var recentRecipes []indexRecentRecipe
146 cached, err := s.cache.Recent(6)
147 if err != nil {
148 log.Printf("cache recent error: %v", err)
149 }
150 for _, cr := range cached {
151 var recipe models.Recipe
152 if err := json.Unmarshal(cr.Recipe, &recipe); err != nil {
153 continue
154 }
155 recentRecipes = append(recentRecipes, indexRecentRecipe{
156 Name: recipe.Name,
157 ImageURL: recipe.ImageURL,
158 SourceURL: cr.URL,
159 Domain: recipe.SourceDomain,
160 })
161 }
162
163 data := map[string]interface{}{
164 "GitHash": s.gitHash,
165 "BaseURL": s.baseURL,
166 "Recent": recentRecipes,
167 }
168 s.templates.ExecuteTemplate(w, "index_page", data)
169}
170
171func (s *Server) handleRecipeQuery(w http.ResponseWriter, r *http.Request) {
172 targetURL := r.URL.Query().Get("url")
173 if targetURL == "" {
174 http.Redirect(w, r, "/", http.StatusFound)
175 return
176 }
177 if strings.HasPrefix(targetURL, "https://") {
178 targetURL = targetURL[8:]
179 } else if strings.HasPrefix(targetURL, "http://") {
180 targetURL = targetURL[7:]
181 }
182 http.Redirect(w, r, "/"+targetURL, http.StatusMovedPermanently)
183}
184
185func (s *Server) handleRecipePath(w http.ResponseWriter, r *http.Request) {
186 path := r.URL.Path
187 if path == "/" || path == "" {
188 http.Redirect(w, r, "/", http.StatusFound)
189 return
190 }
191
192 targetURL := "https://" + path[1:]
193
194 // Handle ?unflag query param
195 if r.URL.Query().Has("unflag") {
196 s.cache.Unflag(targetURL)
197 http.Redirect(w, r, path, http.StatusSeeOther)
198 return
199 }
200
201 // Handle ?refresh query param — clear cache and re-extract
202 if r.URL.Query().Has("refresh") {
203 s.cache.Invalidate(targetURL)
204 s.failedMu.Lock()
205 delete(s.failed, targetURL)
206 s.failedMu.Unlock()
207 // Redirect to same path without query params to avoid loop
208 http.Redirect(w, r, path, http.StatusSeeOther)
209 return
210 }
211
212 recipe, err := s.cache.Get(targetURL)
213 if err != nil {
214 log.Printf("cache read error: %v", err)
215 }
216 if recipe != nil {
217 s.renderRecipe(w, recipe, targetURL)
218 return
219 }
220
221 // Check if extraction already failed (5min TTL)
222 s.failedMu.Lock()
223 entry, alreadyFailed := s.failed[targetURL]
224 s.failedMu.Unlock()
225 if alreadyFailed && time.Since(entry.failedAt) < 5*time.Minute {
226 s.renderError(w, entry.msg, targetURL)
227 return
228 }
229 if alreadyFailed {
230 s.failedMu.Lock()
231 delete(s.failed, targetURL)
232 s.failedMu.Unlock()
233 }
234
235 // Not cached — start extraction if not already in flight
236 s.startExtraction(targetURL)
237
238 // Render loading interstitial
239 data := map[string]interface{}{
240 "TargetURL": targetURL,
241 "GitHash": s.gitHash,
242 "BaseURL": s.baseURL,
243 }
244 w.Header().Set("Content-Type", "text/html; charset=utf-8")
245 s.templates.ExecuteTemplate(w, "loading_page", data)
246}
247
248func (s *Server) startExtraction(targetURL string) {
249 s.pendingMu.Lock()
250 defer s.pendingMu.Unlock()
251
252 if _, ok := s.pending[targetURL]; ok {
253 return
254 }
255
256 ch := make(chan extractResult, 1)
257 s.pending[targetURL] = ch
258
259 go func() {
260 result := s.pipeline.Extract(targetURL)
261 if result.Error != nil {
262 s.failedMu.Lock()
263 s.failed[targetURL] = failedEntry{msg: result.Error.Error(), failedAt: time.Now()}
264 s.failedMu.Unlock()
265
266 ch <- extractResult{err: result.Error}
267 } else {
268 if err := s.cache.Set(targetURL, result.Recipe); err != nil {
269 log.Printf("cache write error: %v", err)
270 }
271 ch <- extractResult{recipe: result.Recipe}
272 }
273 close(ch)
274
275 s.pendingMu.Lock()
276 delete(s.pending, targetURL)
277 s.pendingMu.Unlock()
278 }()
279}
280
281func (s *Server) handleUserscript(w http.ResponseWriter, r *http.Request) {
282 data := map[string]interface{}{
283 "GitHash": s.gitHash,
284 "BaseURL": s.baseURL,
285 }
286 w.Header().Set("Content-Type", "text/html; charset=utf-8")
287 s.templates.ExecuteTemplate(w, "userscript_page", data)
288}
289
290func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
291 targetURL := r.URL.Query().Get("url")
292 if targetURL == "" {
293 w.Header().Set("Content-Type", "application/json")
294 w.Write([]byte(`{"ready":false,"error":"missing url"}`))
295 return
296 }
297
298 recipe, err := s.cache.Get(targetURL)
299 if err != nil {
300 log.Printf("cache read error: %v", err)
301 }
302 if recipe != nil {
303 w.Header().Set("Content-Type", "application/json")
304 w.Write([]byte(`{"ready":true}`))
305 return
306 }
307
308 s.pendingMu.Lock()
309 ch, pending := s.pending[targetURL]
310 s.pendingMu.Unlock()
311 if !pending {
312 // Check if it already failed (5min TTL)
313 s.failedMu.Lock()
314 entry, failed := s.failed[targetURL]
315 s.failedMu.Unlock()
316 if failed {
317 if time.Since(entry.failedAt) < 5*time.Minute {
318 errMsg := strings.ReplaceAll(entry.msg, `"`, `\\"`)
319 w.Header().Set("Content-Type", "application/json")
320 w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg)))
321 return
322 }
323 s.failedMu.Lock()
324 delete(s.failed, targetURL)
325 s.failedMu.Unlock()
326 }
327 w.Header().Set("Content-Type", "application/json")
328 w.Write([]byte(`{"ready":false,"error":"extraction not started"}`))
329 return
330 }
331
332 // Non-blocking read from channel
333 select {
334 case res := <-ch:
335 w.Header().Set("Content-Type", "application/json")
336 if res.err != nil {
337 errMsg := strings.ReplaceAll(res.err.Error(), `"`, `\\"`)
338 w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg)))
339 } else {
340 w.Write([]byte(`{"ready":true}`))
341 }
342 default:
343 w.Header().Set("Content-Type", "application/json")
344 w.Write([]byte(`{"ready":false}`))
345 }
346}
347
348func (s *Server) renderRecipe(w http.ResponseWriter, recipe *models.Recipe, targetURL string) {
349 filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook"
350 data := map[string]interface{}{
351 "Recipe": recipe,
352 "TargetURL": targetURL,
353 "Filename": filename,
354 "GitHash": s.gitHash,
355 "BaseURL": s.baseURL,
356 "IsFlagged": s.cache.IsFlagged(targetURL),
357 }
358 w.Header().Set("Content-Type", "text/html; charset=utf-8")
359 s.templates.ExecuteTemplate(w, "recipe_page", data)
360}
361
362func (s *Server) handleCookView(w http.ResponseWriter, r *http.Request) {
363 targetURL := r.URL.Query().Get("url")
364 if targetURL == "" {
365 http.Error(w, "missing url parameter", http.StatusBadRequest)
366 return
367 }
368 if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
369 targetURL = "https://" + targetURL
370 }
371
372 recipe, err := s.cache.Get(targetURL)
373 if err != nil {
374 log.Printf("cache read error: %v", err)
375 }
376 if recipe == nil {
377 result := s.pipeline.Extract(targetURL)
378 if result.Error != nil {
379 s.renderError(w, result.Error.Error(), targetURL)
380 return
381 }
382 recipe = result.Recipe
383 }
384
385 cook := cooklang.Export(recipe)
386 filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook"
387
388 data := map[string]interface{}{
389 "Recipe": recipe,
390 "TargetURL": targetURL,
391 "CookFile": cook,
392 "Filename": filename,
393 "GitHash": s.gitHash,
394 "BaseURL": s.baseURL,
395 }
396 w.Header().Set("Content-Type", "text/html; charset=utf-8")
397 s.templates.ExecuteTemplate(w, "cook_page", data)
398}
399
400func (s *Server) handleCookExport(w http.ResponseWriter, r *http.Request) {
401 targetURL := r.URL.Query().Get("url")
402 if targetURL == "" {
403 http.Error(w, "missing url parameter", http.StatusBadRequest)
404 return
405 }
406 if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
407 targetURL = "https://" + targetURL
408 }
409
410 recipe, err := s.cache.Get(targetURL)
411 if err != nil {
412 log.Printf("cache read error: %v", err)
413 }
414 if recipe == nil {
415 result := s.pipeline.Extract(targetURL)
416 if result.Error != nil {
417 http.Error(w, result.Error.Error(), http.StatusBadGateway)
418 return
419 }
420 recipe = result.Recipe
421 }
422
423 cook := cooklang.Export(recipe)
424 filename := strings.ReplaceAll(recipe.Name, " ", "-") + ".cook"
425
426 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
427 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
428 w.Write([]byte(cook))
429}
430
431func (s *Server) handleFlag(w http.ResponseWriter, r *http.Request) {
432 targetURL := r.URL.Query().Get("url")
433 if targetURL == "" {
434 targetURL = r.FormValue("url")
435 }
436 if targetURL == "" {
437 http.Error(w, "missing url", http.StatusBadRequest)
438 return
439 }
440
441 recipe, err := s.cache.Get(targetURL)
442 if err != nil {
443 log.Printf("cache read error: %v", err)
444 }
445 if recipe == nil {
446 http.Error(w, "recipe not found", http.StatusNotFound)
447 return
448 }
449
450 if err := s.cache.Flag(targetURL, recipe); err != nil {
451 log.Printf("flag error: %v", err)
452 http.Error(w, "flag failed", http.StatusInternalServerError)
453 return
454 }
455
456 data := map[string]interface{}{
457 "SourceURL": targetURL,
458 "GitHash": s.gitHash,
459 "BaseURL": s.baseURL,
460 }
461 w.Header().Set("Content-Type", "text/html; charset=utf-8")
462 s.templates.ExecuteTemplate(w, "flag_page", data)
463}
464
465func (s *Server) handleFlagged(w http.ResponseWriter, r *http.Request) {
466 flagged, err := s.cache.ListFlagged()
467 if err != nil {
468 log.Printf("list flagged error: %v", err)
469 }
470
471 var recipes []indexRecentRecipe
472 for _, cr := range flagged {
473 var recipe models.Recipe
474 if err := json.Unmarshal(cr.Recipe, &recipe); err != nil {
475 continue
476 }
477 recipes = append(recipes, indexRecentRecipe{
478 Name: recipe.Name,
479 ImageURL: recipe.ImageURL,
480 SourceURL: cr.URL,
481 Domain: recipe.SourceDomain,
482 })
483 }
484
485 data := map[string]interface{}{
486 "GitHash": s.gitHash,
487 "BaseURL": s.baseURL,
488 "Flagged": recipes,
489 }
490 w.Header().Set("Content-Type", "text/html; charset=utf-8")
491 s.templates.ExecuteTemplate(w, "flagged_page", data)
492}
493
494func (s *Server) renderError(w http.ResponseWriter, errMsg, sourceURL string) {
495 data := map[string]interface{}{
496 "Error": errMsg,
497 "SourceURL": sourceURL,
498 "BaseURL": s.baseURL,
499 "GitHash": s.gitHash,
500 }
501 w.Header().Set("Content-Type", "text/html; charset=utf-8")
502 w.WriteHeader(http.StatusBadGateway)
503 s.templates.ExecuteTemplate(w, "error_page", data)
504}
505
506func fmtDuration(iso string) string {
507 if !strings.HasPrefix(iso, "PT") {
508 return iso
509 }
510 d := strings.TrimPrefix(iso, "PT")
511 var parts []string
512 if h := before(d, "H"); h != "" {
513 parts = append(parts, h+"h")
514 d = after(d, "H")
515 }
516 if m := before(d, "M"); m != "" {
517 parts = append(parts, m+"m")
518 d = after(d, "M")
519 }
520 if s := before(d, "S"); s != "" {
521 parts = append(parts, s+"s")
522 }
523 if len(parts) == 0 {
524 return iso
525 }
526 return strings.Join(parts, " ")
527}
528
529func isoToSeconds(iso string) int {
530 if !strings.HasPrefix(iso, "PT") {
531 return 0
532 }
533 d := strings.TrimPrefix(iso, "PT")
534 secs := 0
535 if h := before(d, "H"); h != "" {
536 if v, err := strconv.Atoi(h); err == nil {
537 secs += v * 3600
538 }
539 d = after(d, "H")
540 }
541 if m := before(d, "M"); m != "" {
542 if v, err := strconv.Atoi(m); err == nil {
543 secs += v * 60
544 }
545 d = after(d, "M")
546 }
547 if s := before(d, "S"); s != "" {
548 if v, err := strconv.Atoi(s); err == nil {
549 secs += v
550 }
551 }
552 return secs
553}
554
555func cleanSource(rawURL string) map[string]string {
556 u, err := url.Parse(rawURL)
557 if err != nil {
558 return map[string]string{"host": rawURL, "path": ""}
559 }
560 host := strings.TrimPrefix(u.Host, "www.")
561 path := u.Path
562 if path == "/" {
563 path = ""
564 }
565 if len(path) > 35 {
566 path = path[:32] + "…"
567 }
568 return map[string]string{"host": host, "path": path}
569}
570
571func before(s, sep string) string {
572 i := strings.Index(s, sep)
573 if i < 0 {
574 return ""
575 }
576 return s[:i]
577}
578
579func after(s, sep string) string {
580 i := strings.Index(s, sep)
581 if i < 0 {
582 return s
583 }
584 return s[i+len(sep):]
585}
586
587func renderStep(text string, ingredients []models.Ingredient, lang string) template.HTML {
588 if lang == "" || lang == "en" || strings.HasPrefix(lang, "en-") {
589 annotated := cooklang.AnnotateStepForDisplay(text, ingredients)
590 return cooklang.ParseAndRender(annotated)
591 }
592 return cooklang.ParseAndRender(cooklang.AnnotateTimersOnly(text, lang))
593}
594
595func groupIngredients(ings []models.Ingredient) []ingredientGroup {
596 var groups []ingredientGroup
597 var current *ingredientGroup
598 for i, ing := range ings {
599 if current == nil || ing.Group != current.Name {
600 groups = append(groups, ingredientGroup{Name: ing.Group, StartIdx: i})
601 current = &groups[len(groups)-1]
602 }
603 current.Items = append(current.Items, ing)
604 }
605 return groups
606}
607
608type ingredientGroup struct {
609 Name string
610 Items []models.Ingredient
611 StartIdx int
612}
613
614func cookHighlight(raw string) template.HTML {
615 return cooklang.Highlight(raw)
616}
617
618func recipeToJSONLD(r *models.Recipe) map[string]interface{} {
619 ld := map[string]interface{}{
620 "@context": "https://schema.org",
621 "@type": "Recipe",
622 "name": r.Name,
623 "description": r.Description,
624 "recipeIngredient": ingredientStrings(r.Ingredients),
625 "recipeInstructions": instructionStrings(r.Instructions),
626 }
627 if r.ImageURL != "" {
628 ld["image"] = r.ImageURL
629 }
630 if r.PrepTime != "" {
631 ld["prepTime"] = r.PrepTime
632 }
633 if r.CookTime != "" {
634 ld["cookTime"] = r.CookTime
635 }
636 if r.TotalTime != "" {
637 ld["totalTime"] = r.TotalTime
638 }
639 if r.Yield != "" {
640 ld["recipeYield"] = r.Yield
641 }
642 return ld
643}
644
645func ingredientStrings(ings []models.Ingredient) []string {
646 out := make([]string, len(ings))
647 for i, ing := range ings {
648 out[i] = ing.RawText
649 }
650 return out
651}
652
653func instructionStrings(steps []models.Instruction) []map[string]string {
654 out := make([]map[string]string, len(steps))
655 for i, step := range steps {
656 out[i] = map[string]string{
657 "@type": "HowToStep",
658 "text": step.Text,
659 }
660 }
661 return out
662}