diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 8c1b275..5366bac 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -35,6 +35,11 @@ func migrate(db *sql.DB) error { recipe JSON NOT NULL, extraction_method TEXT NOT NULL, fetched_at DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS flagged_recipes ( + url TEXT PRIMARY KEY, + recipe JSON NOT NULL, + flagged_at DATETIME NOT NULL ) `) return err @@ -105,6 +110,56 @@ func (c *Cache) Recent(limit int) ([]models.CachedRecipe, error) { return results, rows.Err() } +func (c *Cache) Flag(url string, recipe *models.Recipe) error { + recipeJSON, err := json.Marshal(recipe) + if err != nil { + return err + } + _, err = c.db.Exec( + `INSERT INTO flagged_recipes (url, recipe, flagged_at) + VALUES (?, ?, ?) + ON CONFLICT(url) DO UPDATE SET recipe=excluded.recipe, flagged_at=excluded.flagged_at`, + url, recipeJSON, time.Now(), + ) + return err +} + +func (c *Cache) IsFlagged(url string) bool { + var count int + c.db.QueryRow("SELECT COUNT(*) FROM flagged_recipes WHERE url = ?", url).Scan(&count) + return count > 0 +} + +func (c *Cache) Unflag(url string) error { + _, err := c.db.Exec("DELETE FROM flagged_recipes WHERE url = ?", url) + return err +} + +func (c *Cache) Invalidate(url string) error { + _, err := c.db.Exec("DELETE FROM recipes WHERE url = ?", url) + return err +} + +func (c *Cache) ListFlagged() ([]models.CachedRecipe, error) { + rows, err := c.db.Query( + "SELECT url, recipe, '', flagged_at FROM flagged_recipes ORDER BY flagged_at DESC", + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []models.CachedRecipe + for rows.Next() { + var cr models.CachedRecipe + if err := rows.Scan(&cr.URL, &cr.Recipe, &cr.ExtractionMethod, &cr.FetchedAt); err != nil { + return nil, err + } + results = append(results, cr) + } + return results, rows.Err() +} + func (c *Cache) Close() error { return c.db.Close() } \ No newline at end of file diff --git a/main.go b/main.go index bf2736c..d755b9c 100644 --- a/main.go +++ b/main.go @@ -88,6 +88,8 @@ func main() { r.Get("/recipe", srv.handleRecipeQuery) r.Get("/userscript", srv.handleUserscript) r.Get("/status", srv.handleStatus) + r.Get("/flagged", srv.handleFlagged) + r.Get("/flag", srv.handleFlag) r.Get("/*", srv.handleRecipePath) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) @@ -188,6 +190,22 @@ func (s *Server) handleRecipePath(w http.ResponseWriter, r *http.Request) { targetURL := "https://" + path[1:] + // Handle ?unflag query param + if r.URL.Query().Has("unflag") { + s.cache.Unflag(targetURL) + http.Redirect(w, r, path, http.StatusSeeOther) + return + } + + // Handle ?refresh query param — clear cache and re-extract + if r.URL.Query().Has("refresh") { + s.cache.Invalidate(targetURL) + s.failedMu.Lock() + delete(s.failed, targetURL) + s.failedMu.Unlock() + // fall through to normal flow (no cache hit → re-extract) + } + recipe, err := s.cache.Get(targetURL) if err != nil { log.Printf("cache read error: %v", err) @@ -332,6 +350,7 @@ func (s *Server) renderRecipe(w http.ResponseWriter, recipe *models.Recipe, targ "Filename": filename, "GitHash": s.gitHash, "BaseURL": s.baseURL, + "IsFlagged": s.cache.IsFlagged(targetURL), } w.Header().Set("Content-Type", "text/html; charset=utf-8") s.templates.ExecuteTemplate(w, "recipe_page", data) @@ -406,6 +425,69 @@ func (s *Server) handleCookExport(w http.ResponseWriter, r *http.Request) { w.Write([]byte(cook)) } +func (s *Server) handleFlag(w http.ResponseWriter, r *http.Request) { + targetURL := r.URL.Query().Get("url") + if targetURL == "" { + targetURL = r.FormValue("url") + } + if targetURL == "" { + http.Error(w, "missing url", http.StatusBadRequest) + return + } + + recipe, err := s.cache.Get(targetURL) + if err != nil { + log.Printf("cache read error: %v", err) + } + if recipe == nil { + http.Error(w, "recipe not found", http.StatusNotFound) + return + } + + if err := s.cache.Flag(targetURL, recipe); err != nil { + log.Printf("flag error: %v", err) + http.Error(w, "flag failed", http.StatusInternalServerError) + return + } + + data := map[string]interface{}{ + "SourceURL": targetURL, + "GitHash": s.gitHash, + "BaseURL": s.baseURL, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + s.templates.ExecuteTemplate(w, "flag_page", data) +} + +func (s *Server) handleFlagged(w http.ResponseWriter, r *http.Request) { + flagged, err := s.cache.ListFlagged() + if err != nil { + log.Printf("list flagged error: %v", err) + } + + var recipes []indexRecentRecipe + for _, cr := range flagged { + var recipe models.Recipe + if err := json.Unmarshal(cr.Recipe, &recipe); err != nil { + continue + } + recipes = append(recipes, indexRecentRecipe{ + Name: recipe.Name, + ImageURL: recipe.ImageURL, + SourceURL: cr.URL, + Domain: recipe.SourceDomain, + }) + } + + data := map[string]interface{}{ + "GitHash": s.gitHash, + "BaseURL": s.baseURL, + "Flagged": recipes, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + s.templates.ExecuteTemplate(w, "flagged_page", data) +} + func (s *Server) renderError(w http.ResponseWriter, errMsg, sourceURL string) { data := map[string]interface{}{ "Error": errMsg, diff --git a/ui/static/style.css b/ui/static/style.css index 43b725c..ae54c53 100644 --- a/ui/static/style.css +++ b/ui/static/style.css @@ -316,6 +316,42 @@ footer a:hover{color:var(--accent)} } .cook-link:hover{color:var(--accent);background:var(--border);text-decoration:none} +.flag-link{ + font-size:0.7rem; + color:var(--text-muted); + background:var(--check-bg); + padding:0.15rem 0.4rem; + border-radius:4px; + text-decoration:none; + vertical-align:middle; + margin-left:0.4rem; + transition:all 0.15s; + display:inline-flex; + align-items:center; +} +.flag-link:hover{color:var(--accent);background:var(--border);text-decoration:none} +.flag-link.flagged{color:#dc2626} +.flag-link.flagged svg{fill:currentColor} + +.meta-link{ + font-family:'Poppins',system-ui,sans-serif; + font-size:0.65rem; + color:var(--border); + text-decoration:none; + vertical-align:middle; + margin-left:0.4rem; + transition:color 0.15s; +} +.meta-link:hover{color:var(--text-muted);text-decoration:underline} + +.flagged-header{margin-bottom:1.5rem} +.flagged-header h2{font-size:1.5rem;font-weight:600;font-family:'Poppins',system-ui,sans-serif} +.flagged-empty{color:var(--text-muted);font-size:0.95rem} + +.flag-done{text-align:center;padding:3rem 1.5rem} +.flag-done h3{font-family:'Poppins',system-ui,sans-serif;font-size:1.1rem;font-weight:600;margin:1rem 0 0.35rem} +.flag-done p{color:var(--text-muted);font-size:0.9rem;margin-bottom:1.25rem} + .actions{ display:flex; gap:0.75rem; diff --git a/ui/templates/flag.html b/ui/templates/flag.html new file mode 100644 index 0000000..1d0668f --- /dev/null +++ b/ui/templates/flag.html @@ -0,0 +1,29 @@ +{{define "flag_page"}} + + +
+ + +No flagged recipes yet.
+{{end}} +{{.Recipe.Description}}
{{end}}