package api import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" "git.pengzhan.dev/haopengzhan/rsspipe/internal/services" "github.com/go-chi/chi/v5" ) // Entry Handlers func (h *Handler) ListEntries(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, _ := strconv.Atoi(q.Get("limit")) offset, _ := strconv.Atoi(q.Get("offset")) filter := db.EntryFilter{ View: q.Get("view"), FeedID: q.Get("feed_id"), CategoryID: q.Get("category_id"), Search: q.Get("search"), Limit: limit, Offset: offset, } entries, total, err := h.entryService.ListEntries(r.Context(), filter) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } if entries == nil { entries = []models.FeedEntry{} } h.writeJSON(w, http.StatusOK, map[string]interface{}{ "entries": entries, "total": total, "limit": limit, "offset": offset, }) } func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") entry, err := h.entryService.GetEntry(r.Context(), id) if err != nil { if err == sql.ErrNoRows { h.writeError(w, http.StatusNotFound, "entry not found") return } h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, entry) } // PatchEntry handles atomic partial updates to an entry and returns the updated entry + transition diff func (h *Handler) PatchEntry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req models.PatchEntryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request body") return } updated, diff, err := h.entryService.PatchEntry(r.Context(), id, req) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]interface{}{ "entry": updated, "diff": diff, }) } func (h *Handler) UpdateEntryRead(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var body models.UpdateEntryReadRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { h.writeError(w, http.StatusBadRequest, "invalid body") return } updated, _, err := h.entryService.PatchEntry(r.Context(), id, models.PatchEntryRequest{ IsRead: &body.IsRead, }) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": updated.IsRead}) } func (h *Handler) UpdateEntryStar(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var body models.UpdateEntryStarRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { h.writeError(w, http.StatusBadRequest, "invalid body") return } updated, _, err := h.entryService.PatchEntry(r.Context(), id, models.PatchEntryRequest{ IsStarred: &body.IsStarred, }) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": updated.IsStarred}) } func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) { var body models.MarkAllReadRequest _ = json.NewDecoder(r.Body).Decode(&body) if err := h.entryService.MarkAllRead(r.Context(), body.FeedID, body.CategoryID); err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, map[string]string{"message": "marked all as read"}) } // ScrapeEntry triggers readability full text extraction (immediate response for UI, async if ?async=true) func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") entry, err := h.entryService.GetEntry(r.Context(), id) if err != nil { h.writeError(w, http.StatusNotFound, "entry not found") return } idempotencyKey := services.GenerateIdempotencyKey("readability_scrape", id, "manual", entry.CreatedAt.Format("20060102150405")) job, err := h.jobService.Enqueue(r.Context(), "readability_scrape", map[string]interface{}{ "entry_id": id, "url": entry.URL, }, idempotencyKey) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } if r.URL.Query().Get("async") == "true" { h.writeJSON(w, http.StatusAccepted, map[string]interface{}{ "job_id": job.ID, "status": job.Status, "message": "Scrape task queued asynchronously", }) return } if h.jobEngine == nil { h.writeError(w, http.StatusInternalServerError, "job engine not initialized") return } patch, result, err := h.jobEngine.ExecuteDirect(r.Context(), "readability_scrape", job.Payload) if err != nil { h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err)) return } if patch != nil { updated, _, patchErr := h.entryService.PatchEntry(r.Context(), id, *patch) if patchErr != nil { h.writeError(w, http.StatusInternalServerError, patchErr.Error()) return } _ = h.db.UpdateJob(models.Job{ ID: job.ID, Status: "completed", Progress: 100, Result: result, }) h.writeJSON(w, http.StatusOK, updated) return } updated, _ := h.entryService.GetEntry(r.Context(), id) h.writeJSON(w, http.StatusOK, updated) } // GenerateAISummary generates an AI summary (immediate response for UI, async if ?async=true) func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") entry, err := h.entryService.GetEntry(r.Context(), id) if err != nil { h.writeError(w, http.StatusNotFound, "entry not found") return } var body struct { Prompt string `json:"prompt,omitempty"` } _ = json.NewDecoder(r.Body).Decode(&body) idempotencyKey := services.GenerateIdempotencyKey("ai_summary", id, "manual", entry.CreatedAt.Format("20060102150405")) job, err := h.jobService.Enqueue(r.Context(), "ai_summary", map[string]interface{}{ "entry_id": id, "prompt": body.Prompt, }, idempotencyKey) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } if r.URL.Query().Get("async") == "true" { h.writeJSON(w, http.StatusAccepted, map[string]interface{}{ "job_id": job.ID, "status": job.Status, "message": "AI summary task queued asynchronously", }) return } if h.jobEngine == nil { h.writeError(w, http.StatusInternalServerError, "job engine not initialized") return } patch, result, err := h.jobEngine.ExecuteDirect(r.Context(), "ai_summary", job.Payload) if err != nil { h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("AI summary generation failed: %v", err)) return } if patch != nil { updated, _, patchErr := h.entryService.PatchEntry(r.Context(), id, *patch) if patchErr != nil { h.writeError(w, http.StatusInternalServerError, patchErr.Error()) return } _ = h.db.UpdateJob(models.Job{ ID: job.ID, Status: "completed", Progress: 100, Result: result, }) h.writeJSON(w, http.StatusOK, updated) return } updated, _ := h.entryService.GetEntry(r.Context(), id) h.writeJSON(w, http.StatusOK, updated) } func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") shareUUID, err := h.entryService.ToggleShare(r.Context(), id) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } entry, _ := h.entryService.GetEntry(r.Context(), id) h.writeJSON(w, http.StatusOK, map[string]interface{}{ "shared": shareUUID != nil, "share_uuid": shareUUID, "entry": entry, }) } func (h *Handler) ListEntryVariants(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") variants, err := h.db.ListVariantsForEntry(id) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return } h.writeJSON(w, http.StatusOK, variants) } func (h *Handler) GetVariantBlobStream(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") key := chi.URLParam(r, "key") variant, err := h.db.GetVariant(id, key) if err != nil { h.writeError(w, http.StatusNotFound, "variant not found") return } if variant.BlobPath == "" { h.writeError(w, http.StatusNotFound, "variant has no binary media blob") return } http.ServeFile(w, r, variant.BlobPath) } // ViewSharedEntry renders a styled standalone HTML page for an explicitly shared entry (strictly checked) func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) { uuidStr := chi.URLParam(r, "uuid") entry, err := h.entryService.GetSharedEntry(r.Context(), uuidStr) if err != nil { http.Error(w, "Shared article not found", http.StatusNotFound) return } // Render standalone styled HTML html := fmt.Sprintf(` %s - RSSPipe
%s • %s %s

%s ↗

%s
`, entry.Title, entry.FeedTitle, entry.PublishedAt.Format("Jan 02, 2006 15:04"), func() string { if entry.Author != "" { return "•By " + entry.Author + "" } return "" }(), entry.URL, entry.Title, entry.Content, ) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(html)) }