diff --git a/go.mod b/go.mod index 5b817c9..2aa7047 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.2 require ( github.com/aquilax/cooklang-go v0.2.0 github.com/go-chi/chi/v5 v5.2.5 + github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.42 golang.org/x/net v0.53.0 ) diff --git a/go.sum b/go.sum index e06be6a..4da2a78 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/aquilax/cooklang-go v0.2.0 h1:oC0hGjqSkjPSNz0FGb9bwTvOYgX2euUxOQWDzzO github.com/aquilax/cooklang-go v0.2.0/go.mod h1:w8UlyehrdabhHxM1Qg+c6U0Scthpy8OOQbCEmtNyvTY= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= diff --git a/internal/extract/pipeline.go b/internal/extract/pipeline.go index c3d1d74..867a2b0 100644 --- a/internal/extract/pipeline.go +++ b/internal/extract/pipeline.go @@ -75,7 +75,7 @@ func (p *Pipeline) Extract(targetURL string) *Result { return &Result{Recipe: recipe} } - return &Result{Error: fmt.Errorf("no recipe found on page — tried JSON-LD, microdata, and h-recipe extraction")} + return &Result{Error: fmt.Errorf("no recipe found on page - tried JSON-LD, microdata, and h-recipe extraction")} } func (p *Pipeline) fetch(url string) (string, error) { diff --git a/main.go b/main.go index 638262f..98fd343 100644 --- a/main.go +++ b/main.go @@ -4,15 +4,18 @@ import ( "flag" "fmt" "html/template" + "io/fs" + "sync" "log" "net/http" "net/url" "strconv" "strings" - "io/fs" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/joho/godotenv" "tangled.org/dunkirk.sh/pare/internal/cache" "tangled.org/dunkirk.sh/pare/internal/cooklang" @@ -24,6 +27,7 @@ import ( var gitHash = "dev" func main() { + godotenv.Load() port := flag.Int("port", 3000, "port to listen on") dbPath := flag.String("db", "pare.db", "path to SQLite database") baseURL := flag.String("base-url", "", "base URL of this service") @@ -44,6 +48,7 @@ func main() { "isoToSeconds": isoToSeconds, "cleanSource": cleanSource, "renderStep": renderStep, + "trimProto": func(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") }, }).ParseFS(ui.Templates, "templates/*.html") if err != nil { log.Fatalf("parsing templates: %v", err) @@ -55,6 +60,8 @@ func main() { templates: tmpl, baseURL: *baseURL, gitHash: gitHash, + pending: make(map[string]chan extractResult), + failed: make(map[string]failedEntry), } r := chi.NewRouter() @@ -68,8 +75,10 @@ func main() { } r.Get("/", srv.handleIndex) - r.Get("/recipe", srv.handleRecipe) r.Get("/export.cook", srv.handleCookExport) + r.Get("/recipe", srv.handleRecipeQuery) + r.Get("/status", srv.handleStatus) + r.Get("/*", srv.handleRecipePath) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) addr := fmt.Sprintf(":%d", *port) @@ -80,56 +89,198 @@ func main() { } type Server struct { - pipeline *extract.Pipeline - cache *cache.Cache - templates *template.Template - baseURL string - gitHash string + pipeline *extract.Pipeline + cache *cache.Cache + templates *template.Template + baseURL string + gitHash string + pendingMu sync.Mutex + pending map[string]chan extractResult + failedMu sync.Mutex + failed map[string]failedEntry +} + +type failedEntry struct { + msg string + failedAt time.Time +} + +type extractResult struct { + recipe *models.Recipe + err error } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { targetURL := r.URL.Query().Get("url") if targetURL != "" { - http.Redirect(w, r, "/recipe?url="+url.QueryEscape(targetURL), http.StatusFound) + if strings.HasPrefix(targetURL, "https://") { + targetURL = targetURL[8:] + } else if strings.HasPrefix(targetURL, "http://") { + targetURL = targetURL[7:] + } + http.Redirect(w, r, "/"+targetURL, http.StatusFound) return } s.templates.ExecuteTemplate(w, "index_page", map[string]string{"GitHash": s.gitHash, "BaseURL": s.baseURL}) } -func (s *Server) handleRecipe(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleRecipeQuery(w http.ResponseWriter, r *http.Request) { targetURL := r.URL.Query().Get("url") if targetURL == "" { http.Redirect(w, r, "/", http.StatusFound) return } + if strings.HasPrefix(targetURL, "https://") { + targetURL = targetURL[8:] + } else if strings.HasPrefix(targetURL, "http://") { + targetURL = targetURL[7:] + } + http.Redirect(w, r, "/"+targetURL, http.StatusMovedPermanently) +} - if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { - targetURL = "https://" + targetURL +func (s *Server) handleRecipePath(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "/" || path == "" { + http.Redirect(w, r, "/", http.StatusFound) + return } + targetURL := "https://" + path[1:] + recipe, err := s.cache.Get(targetURL) if err != nil { log.Printf("cache read error: %v", err) } - if recipe == nil { + if recipe != nil { + s.renderRecipe(w, recipe, targetURL) + return + } + + // Check if extraction already failed (5min TTL) + s.failedMu.Lock() + entry, alreadyFailed := s.failed[targetURL] + s.failedMu.Unlock() + if alreadyFailed && time.Since(entry.failedAt) < 5*time.Minute { + s.renderError(w, entry.msg, targetURL) + return + } + if alreadyFailed { + s.failedMu.Lock() + delete(s.failed, targetURL) + s.failedMu.Unlock() + } + + // Not cached — start extraction if not already in flight + s.startExtraction(targetURL) + + // Render loading interstitial + data := map[string]interface{}{ + "TargetURL": targetURL, + "GitHash": s.gitHash, + "BaseURL": s.baseURL, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + s.templates.ExecuteTemplate(w, "loading_page", data) +} + +func (s *Server) startExtraction(targetURL string) { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + + if _, ok := s.pending[targetURL]; ok { + return + } + + ch := make(chan extractResult, 1) + s.pending[targetURL] = ch + + go func() { result := s.pipeline.Extract(targetURL) if result.Error != nil { - s.renderError(w, result.Error.Error(), targetURL) - return + s.failedMu.Lock() + s.failed[targetURL] = failedEntry{msg: result.Error.Error(), failedAt: time.Now()} + s.failedMu.Unlock() + + ch <- extractResult{err: result.Error} + } else { + if err := s.cache.Set(targetURL, result.Recipe); err != nil { + log.Printf("cache write error: %v", err) + } + ch <- extractResult{recipe: result.Recipe} } - recipe = result.Recipe - if err := s.cache.Set(targetURL, recipe); err != nil { - log.Printf("cache write error: %v", err) + close(ch) + + s.pendingMu.Lock() + delete(s.pending, targetURL) + s.pendingMu.Unlock() + }() +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + targetURL := r.URL.Query().Get("url") + if targetURL == "" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ready":false,"error":"missing url"}`)) + return + } + + recipe, err := s.cache.Get(targetURL) + if err != nil { + log.Printf("cache read error: %v", err) + } + if recipe != nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ready":true}`)) + return + } + + s.pendingMu.Lock() + ch, pending := s.pending[targetURL] + s.pendingMu.Unlock() + if !pending { + // Check if it already failed (5min TTL) + s.failedMu.Lock() + entry, failed := s.failed[targetURL] + s.failedMu.Unlock() + if failed { + if time.Since(entry.failedAt) < 5*time.Minute { + errMsg := strings.ReplaceAll(entry.msg, `"`, `\\"`) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg))) + return + } + s.failedMu.Lock() + delete(s.failed, targetURL) + s.failedMu.Unlock() } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ready":false,"error":"extraction not started"}`)) + return } + // Non-blocking read from channel + select { + case res := <-ch: + w.Header().Set("Content-Type", "application/json") + if res.err != nil { + errMsg := strings.ReplaceAll(res.err.Error(), `"`, `\\"`) + w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg))) + } else { + w.Write([]byte(`{"ready":true}`)) + } + default: + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ready":false}`)) + } +} + +func (s *Server) renderRecipe(w http.ResponseWriter, recipe *models.Recipe, targetURL string) { data := map[string]interface{}{ "Recipe": recipe, "TargetURL": targetURL, "GitHash": s.gitHash, "BaseURL": s.baseURL, } - w.Header().Set("Content-Type", "text/html; charset=utf-8") s.templates.ExecuteTemplate(w, "recipe_page", data) } @@ -140,6 +291,9 @@ func (s *Server) handleCookExport(w http.ResponseWriter, r *http.Request) { http.Error(w, "missing url parameter", http.StatusBadRequest) return } + if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { + targetURL = "https://" + targetURL + } recipe, err := s.cache.Get(targetURL) if err != nil { diff --git a/ui/static/style.css b/ui/static/style.css index e227966..bb034c5 100644 --- a/ui/static/style.css +++ b/ui/static/style.css @@ -341,6 +341,48 @@ footer a:hover{color:var(--accent)} color:var(--accent); } +.loading-box{ + text-align:center; + padding:4rem 1.5rem; +} +.loading-box h3{ + font-family:'Poppins',system-ui,sans-serif; + font-size:1.1rem; + font-weight:600; + color:var(--text); + margin-bottom:0.5rem; +} +.loading-status{ + font-size:0.9rem; + color:var(--text-muted); + margin-bottom:0.35rem; +} +.loading-source{ + font-size:0.8rem; + font-family:'Poppins',system-ui,sans-serif; +} +.loading-source a{ + color:var(--text); + text-decoration:none; +} +.loading-source a:hover{ + color:var(--accent); + text-decoration:underline; +} +.loading-source a:hover .source-path{ + color:var(--accent); +} +.loading-spinner{ + width:32px; + height:32px; + border:3px solid var(--border); + border-top-color:var(--accent); + border-radius:50%; + margin:0 auto 1.5rem; + animation:spin 0.8s linear infinite; +} +@keyframes spin{to{transform:rotate(360deg)}} + .bookmarklet{ margin-top:2rem; padding:1rem; diff --git a/ui/templates/error.html b/ui/templates/error.html index 64defd6..82b1ebc 100644 --- a/ui/templates/error.html +++ b/ui/templates/error.html @@ -14,7 +14,7 @@
{{.Error}}
You can view the original page at {{with cleanSource .SourceURL}}{{.host}}{{if .path}}{{.path}}{{end}}{{end}}