Files
rsspipe/internal/api/entries_handler.go
haopengzhan ebcaa5d72a
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s
refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
2026-09-01 00:41:05 -07:00

361 lines
11 KiB
Go

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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s - RSSPipe</title>
<style>
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --muted: #94a3b8; --accent: #38bdf8; }
@media (prefers-color-scheme: light) {
:root { --bg: #f8fafc; --card: #ffffff; --text: #0f172a; --muted: #64748b; --accent: #0284c7; }
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; margin: 0; padding: 2rem 1rem; display: flex; justify-content: center; }
.container { max-width: 760px; width: 100%%; background: var(--card); border-radius: 12px; padding: 2.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; border-bottom: 1px solid rgba(148, 163, 184, 0.2); padding-bottom: 1rem; }
h1 { margin-top: 0; font-size: 2rem; line-height: 1.3; }
h1 a { color: inherit; text-decoration: none; }
h1 a:hover { color: var(--accent); }
.content img { max-width: 100%%; height: auto; border-radius: 8px; }
.content pre { background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; overflow-x: auto; }
.footer { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(148, 163, 184, 0.2); font-size: 0.85rem; color: var(--muted); text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="meta">
<span><strong>%s</strong></span>
<span>•</span>
<span>%s</span>
%s
</div>
<h1><a href="%s" target="_blank" rel="noopener noreferrer">%s ↗</a></h1>
<div class="content">
%s
</div>
<div class="footer">
Shared via <a href="/" style="color: var(--accent); text-decoration: none;">RSSPipe Information Gateway</a>
</div>
</div>
</body>
</html>`,
entry.Title,
entry.FeedTitle,
entry.PublishedAt.Format("Jan 02, 2006 15:04"),
func() string {
if entry.Author != "" {
return "<span>•</span><span>By " + entry.Author + "</span>"
}
return ""
}(),
entry.URL,
entry.Title,
entry.Content,
)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}