diff --git a/cmd/server/main.go b/cmd/server/main.go
index 9d6af85..d4d0b6d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -17,6 +17,7 @@ import (
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/services"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
)
@@ -46,24 +47,37 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- // 3. Initialize EventBus, WorkerPool, and Action Handlers
+ // 3. Initialize EventBus
bus := events.NewEventBus()
- pool := worker.NewPool(database, bus, 5, 1000)
- // Register Core Action Handlers (readability, status, variants, AI enrich & triage)
- actions.RegisterDefaultHandlers(pool, database, log)
- pool.Start(ctx)
+ // 4. Initialize Domain Services
+ entryService := services.NewEntryService(database, bus)
+ jobService := services.NewJobService(database, bus)
+ settingsService := services.NewSettingsService(database, bus)
- // 4. Initialize Rules Engine
- engine := rules.NewEngine(database, bus, pool)
- engine.Start(ctx)
-
- // 5. Start Background Crawler Pool
+ // 5. Start Background Crawler
cr := crawler.NewCrawler(database, bus, cfg.CrawlerIntervalMinutes, cfg.CrawlerWorkers)
cr.Start(ctx)
- // 6. Mount API Routes
- handler := api.NewHandler(database, authService, cr, pool)
+ feedService := services.NewFeedService(database, entryService, cr)
+
+ // 6. Initialize Persistent SQLite Task Queue Engine
+ jobEngine := worker.NewJobEngine(database, bus, entryService, 10)
+ actions.RegisterJobEngineHandlers(jobEngine, database, log)
+ jobEngine.Start(ctx)
+
+ // 7. Legacy WorkerPool (for backward compatibility if needed)
+ pool := worker.NewPool(database, bus, 5, 1000)
+ actions.RegisterDefaultHandlers(pool, database, log)
+ pool.Start(ctx)
+
+ // 8. Initialize Rules Engine (Transition-Aware Evaluator)
+ rulesEngine := rules.NewEngine(database, bus, pool)
+ rulesEngine.SetEnqueuer(jobService)
+ rulesEngine.Start(ctx)
+
+ // 9. Mount API Routes
+ handler := api.NewHandler(database, authService, bus, entryService, feedService, jobService, settingsService, jobEngine)
router := api.NewRouter(handler, authService, "./web/dist")
server := &http.Server{
@@ -74,7 +88,7 @@ func main() {
IdleTimeout: 120 * time.Second,
}
- // 7. Graceful shutdown handler
+ // 10. Graceful shutdown handler
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
@@ -82,6 +96,7 @@ func main() {
log.Info("Shutting down RSSPipe gracefully...")
cancel()
+ jobEngine.Stop()
pool.Stop()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
diff --git a/docs/009_worklog.md b/docs/009_worklog.md
new file mode 100644
index 0000000..8d95972
--- /dev/null
+++ b/docs/009_worklog.md
@@ -0,0 +1,156 @@
+# RSSPipe Worklog & Architectural Refactoring (`009_worklog.md`)
+
+This document records the comprehensive architectural overhaul, bug fixes, and engineering enhancements implemented across the RSSPipe frontend and backend.
+
+---
+
+## 1. Overview & Objectives
+
+The primary goal of this milestone was to transform RSSPipe into a resilient, reactive, and easily extensible event-driven platform capable of executing asynchronous pipelines (e.g. readability extraction, AI summarization, tagging, text-to-speech) without concurrency bottlenecks or UI state drift.
+
+### Core Problems Addressed
+
+1. **Monolithic API Structure**: A single 1,140-line `handlers.go` held all business logic, making feature expansion error-prone.
+2. **Missing State Transitions**: Internal background updates (e.g., crawler writes, variant additions) lacked declarative event propagation.
+3. **Task Concurrency & Recovery**: Long-running or rate-limited tasks needed persistent queuing, exponential backoff retries, and crash recovery.
+4. **SQLite Concurrency & Lock Collisions**: Concurrent worker writes threw `SQLITE_BUSY (5)` due to pool-level PRAGMA isolation.
+5. **Cross-Tab State Synchronization**: Updating read/star status in one session did not reflect in other sessions or tabs.
+6. **Mobile UX on iOS**: Fixed bottom action bars overlapping with the iOS virtual home indicator bar.
+
+---
+
+## 2. Architectural Changes
+
+```
+ ┌───────────────────────────────────────────────────────────┐
+ │ HTTP API / Worker │
+ └─────────────────────────────┬─────────────────────────────┘
+ │
+ ▼
+ ┌──────────────────────────────────┐
+ │ EntryService.PatchEntry() │
+ │ (Atomic DB Update + EntryDiff) │
+ └────────────────┬─────────────────┘
+ │
+ ▼
+ ┌──────────────────────────────────┐
+ │ EventBus.Publish(Transition) │
+ └────────┬─────────────────┬───────┘
+ │ │
+ ┌────────────────────┘ └────────────────────┐
+ ▼ ▼
+┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
+│ Server-Sent Events (SSE) │ │ RulesEngine │
+│ GET /api/events/stream │ │ (Evaluate Old vs New State) │
+└──────────────────┬───────────────────┘ └──────────────────┬───────────────────┘
+ │ │
+ ▼ ▼
+┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
+│ Client Tabs (Real-Time Reactive UI) │ │ JobEngine.Enqueue(Job) │
+└──────────────────────────────────────┘ └──────────────────┬───────────────────┘
+ │
+ ▼
+ ┌──────────────────────────────────────┐
+ │ Persistent SQLite `jobs` Table │
+ │ (Workers, Retries, Idempotency) │
+ └──────────────────────────────────────┘
+```
+
+### 2.1 Atomic State Transitions & Event Bus
+
+- Implemented `PatchEntry(ctx, id, patch)` in `internal/services/entry_service.go`.
+- Computes atomic `EntryDiff` comparing pre-update and post-update state:
+ - Boolean flag changes (`is_read`, `is_starred`).
+ - Added or updated variants (e.g. `readability_full_text`, `ai_summary`, `tts_audio`).
+- Publishes `EventEntryTransition(OldState, NewState, Diff)` onto `internal/events/bus.go`.
+- Enables declarative rules evaluation on every state change without tight coupling.
+
+### 2.2 Persistent SQLite Job Queue (`JobEngine`)
+
+- Created `jobs` table in SQLite schema with lifecycle states (`pending`, `running`, `completed`, `failed`, `dead`).
+- Added worker pool in `internal/worker/engine.go` with:
+ - Concurrency limits (default 5 concurrent workers).
+ - Exponential backoff retries (`run_at` scheduling).
+ - Idempotency key deduplication (prevents duplicate execution of identical tasks).
+ - Crash recovery on engine startup (resets zombie `running` jobs back to `pending`).
+- Registered handlers for:
+ - `readability_scrape` (Readability full-text extraction).
+ - `ai_summary` / `ai_enrich` (LLM summarization and variant generation).
+ - `mark_read` / `mark_starred` (Status modifications).
+ - `set_variant` (Structured tag and variant persistence).
+
+### 2.3 Modular Backend HTTP Handlers
+
+Decomposed the monolithic `handlers.go` into domain-specific files under `internal/api/`:
+
+- `auth_handler.go`: Authentication, session verification, login, and logout.
+- `feeds_handler.go`: Feed CRUD, OPML import/export, manual refresh, and favicon resolution.
+- `entries_handler.go`: Entry queries, filtering, atomic patch, scrape, and AI actions.
+- `jobs_handler.go`: Job queue querying, execution status, and asynchronous dispatch.
+- `settings_handler.go`: User preferences and AI provider configuration.
+- `rules_handler.go`: Automation rule CRUD and test execution.
+- `sse_handler.go`: Server-Sent Events real-time event streaming.
+
+### 2.4 Real-Time Reactive Synchronization (SSE)
+
+- Added `GET /api/events/stream` endpoint authenticated via Bearer token / session cookie.
+- Connected event stream in `web/src/App.tsx` via `api.connectEventStream()`.
+- Automatically syncs read status, star status, and variant additions in real-time across multiple open browser tabs or devices.
+
+---
+
+## 3. Critical Bug Fixes
+
+### 3.1 SQLite `SQLITE_BUSY (5)` Database Lock Fix
+
+- **Root Cause**: In Go's `database/sql`, executing `PRAGMA busy_timeout = 5000;` on startup only applied to a single pooled connection. Subsequent pool connections defaulted to `0ms` timeout, immediately throwing `SQLITE_BUSY` when concurrent writes occurred.
+- **Solution**: Configured PRAGMAs directly in the SQLite DSN connection string in `internal/db/db.go`:
+
+ ```go
+ dsn := fmt.Sprintf("%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=cache_size(-20000)", dbPath)
+ ```
+
+ Every connection checked out of the pool now inherits a 10-second busy timeout and WAL mode.
+
+### 3.2 AI Settings & Prompt Template Unification
+
+- **Root Cause**: `SettingsService` saved AI configuration as a unified JSON object under key `"ai_settings"`, but `LoadConfig` in the AI actions was reading non-existent legacy individual keys (`ai_enabled`, `ai_api_key`). This caused prompt templates to be ignored and disabled states to be bypassed.
+- **Solution**: Refactored `LoadConfig` in `internal/actions/ai/enrich.go` to unmarshal `"ai_settings"` JSON. Enforced strict enabled checks and wired custom prompt templates (e.g. prompt `"a": "aaa"`).
+
+### 3.3 Undo Share State Reset
+
+- **Root Cause**: `models.FeedEntry` had `omitempty` on `ShareUUID *string`, which omitted `share_uuid` from JSON responses when set to `nil` rather than outputting `null`.
+- **Solution**: Removed `omitempty` from `ShareUUID` in `internal/models/models.go` and updated `DesktopReader.tsx` and `MobileArticleSheet.tsx` to explicitly reset `share_uuid: res.share_uuid ?? null`.
+
+### 3.4 Middle Bar Unread / All Count Discrepancy
+
+- **Root Cause**: The middle article list header count displayed in-memory array lengths (`entries.length`) rather than true database statistics from `stats` and feed/category metadata.
+- **Solution**: Updated `DesktopArticleList.tsx` to compute true database counts from `stats` and feed totals, and reverted switch buttons to clean `Unread` and `All` labels.
+
+### 3.5 Mobile iOS Safe Area Insets & Home Indicator
+
+- **Root Cause**: `viewport-fit=cover` was missing in `web/index.html`, causing iOS Safari to evaluate `env(safe-area-inset-bottom)` to `0px`.
+- **Solution**: Added `viewport-fit=cover` and calibrated bottom navigation height (`50px + env(safe-area-inset-bottom, 0px)`) and article bottom toolbar height (`48px + env(safe-area-inset-bottom, 0px)`).
+
+---
+
+## 4. Work Items & Backlog for Future Milestones
+
+The following items have been identified for future implementation:
+
+1. **SSE Auto-Reconnect & Connection State Recovery**:
+ - Exponential backoff with jitter on EventSource disconnection in `web/src/api.ts`.
+ - Snapshot reconciliation (`/api/stats` and `/api/entries?since=...`) upon reconnect.
+
+2. **Background Job Queue & Dead-Letter Management in Admin Settings**:
+ - Dedicated "Job Queue" tab in `Settings > Account & System`.
+ - Live table of tasks (`pending`, `running`, `completed`, `failed`, `dead`) with error logs and manual retry/cancel actions.
+
+3. **Reader UI Prompt Template Selector**:
+ - Dropdown selector next to "Generate AI Summary" button in Desktop and Mobile reader bars to choose between configured prompt templates on the fly.
+
+4. **Local Ollama Model Autodiscovery & Health Check**:
+ - "Discover Models" button in `Settings > AI & Integrations` querying `http://localhost:11434/api/tags` to populate the model list automatically.
+
+5. **Desktop 3-Pane Middle List Infinite Scrolling**:
+ - Windowed offset pagination (`limit: 50`, `offset: entries.length`) with scroll threshold trigger on `.desktop-articles-scroll`.
diff --git a/internal/actions/ai/enrich.go b/internal/actions/ai/enrich.go
index 64fd9ac..9d5741a 100644
--- a/internal/actions/ai/enrich.go
+++ b/internal/actions/ai/enrich.go
@@ -2,6 +2,7 @@ package ai
import (
"context"
+ "encoding/json"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
@@ -12,7 +13,35 @@ import (
)
// LoadConfig fetches current AI provider configuration from system settings.
-func LoadConfig(database *db.DB) ai.Config {
+func LoadConfig(database *db.DB) (ai.Config, []models.PromptTemplate) {
+ raw, err := database.GetSetting("ai_settings")
+ if err == nil && raw != "" {
+ var s models.AISettings
+ if err := json.Unmarshal([]byte(raw), &s); err == nil {
+ provider := s.Provider
+ if provider == "" {
+ provider = "openai"
+ }
+ baseURL := s.BaseURL
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+ model := s.Model
+ if model == "" {
+ model = "gpt-4o-mini"
+ }
+ return ai.Config{
+ Enabled: s.Enabled,
+ Provider: provider,
+ BaseURL: baseURL,
+ APIKey: s.APIKey,
+ Model: model,
+ Temperature: s.Temperature,
+ MaxTokens: s.MaxTokens,
+ }, s.Prompts
+ }
+ }
+
settings, _ := database.GetSettingsMap()
enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
provider := settings["ai_provider"]
@@ -33,13 +62,19 @@ func LoadConfig(database *db.DB) ai.Config {
BaseURL: baseURL,
APIKey: settings["ai_api_key"],
Model: model,
- }
+ }, nil
}
// NewEnrichmentHandler creates a worker action handler for AI variant enrichment.
func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
- cfg := LoadConfig(database)
+ cfg, prompts := LoadConfig(database)
+ if !cfg.Enabled {
+ log.Warn("AI action skipped: AI features are disabled in Settings",
+ zap.String("entry_id", entry.ID),
+ )
+ return nil, nil
+ }
if cfg.APIKey == "" && cfg.Provider != "ollama" {
log.Warn("AI action skipped: API key is not configured in Settings > AI & Integrations",
zap.String("entry_id", entry.ID),
@@ -52,6 +87,17 @@ func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) w
if prompt == "" {
prompt, _ = params["custom_prompt"].(string)
}
+ if prompt == "" {
+ for _, p := range prompts {
+ if p.IsDefault {
+ prompt = p.Prompt
+ break
+ }
+ }
+ if prompt == "" && len(prompts) > 0 {
+ prompt = prompts[0].Prompt
+ }
+ }
vKey, _ := params["variant_key"].(string)
if vKey == "" {
vKey = "ai_summary"
diff --git a/internal/actions/ai/triage.go b/internal/actions/ai/triage.go
index 51f12b5..ed28466 100644
--- a/internal/actions/ai/triage.go
+++ b/internal/actions/ai/triage.go
@@ -15,7 +15,11 @@ import (
// NewTriageHandler creates a worker action handler for AI autonomous triage decisions.
func NewTriageHandler(database *db.DB, client *ai.Client, pool *worker.Pool, log *zap.Logger) worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
- cfg := LoadConfig(database)
+ cfg, _ := LoadConfig(database)
+ if !cfg.Enabled {
+ log.Warn("AI triage skipped: AI features are disabled in Settings", zap.String("entry_id", entry.ID))
+ return nil, nil
+ }
if cfg.APIKey == "" && cfg.Provider != "ollama" {
log.Warn("AI triage action skipped: API key is not configured in Settings > AI & Integrations",
zap.String("entry_id", entry.ID),
diff --git a/internal/actions/register.go b/internal/actions/register.go
index c43a694..ad07e0a 100644
--- a/internal/actions/register.go
+++ b/internal/actions/register.go
@@ -1,6 +1,9 @@
package actions
import (
+ "context"
+ "fmt"
+ "strings"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
@@ -9,11 +12,12 @@ import (
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/variants"
aiClientPkg "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"go.uber.org/zap"
)
-// RegisterDefaultHandlers registers all core action handlers with the worker pool.
+// RegisterDefaultHandlers registers legacy action handlers with worker.Pool
func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger) {
// 1. Readability Full-Text Scraper
scraper := readability.NewScraper(15 * time.Second)
@@ -34,3 +38,189 @@ func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger
pool.RegisterHandler("ai_agent_action", ai.NewTriageHandler(database, aiClient, pool, log))
}
+
+// RegisterJobEngineHandlers registers async task handlers with the persistent JobEngine
+func RegisterJobEngineHandlers(engine *worker.JobEngine, database *db.DB, log *zap.Logger) {
+ scraper := readability.NewScraper(20 * time.Second)
+ aiClient := aiClientPkg.NewClient(60 * time.Second)
+
+ // 1. Readability Scrape Handler
+ engine.RegisterHandler("readability_scrape", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
+ entryID, _ := job.Payload["entry_id"].(string)
+ if entryID == "" {
+ return nil, nil, fmt.Errorf("missing entry_id in job payload")
+ }
+
+ entry, err := database.GetEntry(entryID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("entry not found: %w", err)
+ }
+
+ res, err := scraper.Handler()(ctx, *entry, job.Payload)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if res == nil || res.ResultVariant == nil {
+ return nil, nil, nil
+ }
+
+ patch := &models.PatchEntryRequest{
+ Variants: map[string]*models.EntryVariant{
+ "readability_full_text": res.ResultVariant,
+ },
+ }
+
+ return patch, map[string]interface{}{
+ "variant_key": "readability_full_text",
+ "length": len(res.ResultVariant.TextValue),
+ }, nil
+ })
+
+ // 2. AI Summary & AI Enrich Handler
+ aiHandler := func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
+ entryID, _ := job.Payload["entry_id"].(string)
+ if entryID == "" {
+ return nil, nil, fmt.Errorf("missing entry_id in job payload")
+ }
+
+ entry, err := database.GetEntry(entryID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("entry not found: %w", err)
+ }
+
+ cfg, prompts := ai.LoadConfig(database)
+ if !cfg.Enabled {
+ return nil, nil, fmt.Errorf("AI features are disabled. Please enable AI in Settings > AI & Integrations")
+ }
+ if cfg.Provider != "ollama" && strings.TrimSpace(cfg.APIKey) == "" {
+ return nil, nil, fmt.Errorf("AI API key is missing. Please configure an API key in Settings > AI & Integrations")
+ }
+
+ prompt, _ := job.Payload["prompt"].(string)
+ if prompt == "" {
+ prompt, _ = job.Payload["custom_prompt"].(string)
+ }
+ if prompt == "" {
+ for _, p := range prompts {
+ if p.IsDefault {
+ prompt = p.Prompt
+ break
+ }
+ }
+ if prompt == "" && len(prompts) > 0 {
+ prompt = prompts[0].Prompt
+ }
+ }
+ if prompt == "" {
+ prompt = "You are an expert technical editor. Summarize the following article in exactly 3 concise bullet points, followed by a bold 'Key Takeaway:' sentence.\n\nTitle: {{title}}\nFeed: {{feed_title}}\nContent:\n{{content}}"
+ }
+
+ vKey, _ := job.Payload["variant_key"].(string)
+ if vKey == "" {
+ vKey = "ai_summary"
+ }
+ cType, _ := job.Payload["content_type"].(string)
+ if cType == "" {
+ cType = "text"
+ }
+
+ resultText, err := aiClient.GenerateEnrichment(ctx, cfg, prompt, *entry)
+ if err != nil {
+ return nil, nil, fmt.Errorf("ai generation failed: %w", err)
+ }
+
+ variant := models.EntryVariant{
+ EntryID: entry.ID,
+ VariantKey: vKey,
+ ContentType: cType,
+ TextValue: resultText,
+ Metadata: map[string]interface{}{
+ "model": cfg.Model,
+ "provider": cfg.Provider,
+ "prompt_used": prompt,
+ },
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+
+ patch := &models.PatchEntryRequest{
+ Variants: map[string]*models.EntryVariant{
+ vKey: &variant,
+ },
+ }
+
+ return patch, map[string]interface{}{
+ "variant_key": vKey,
+ "model": cfg.Model,
+ "word_count": len(strings.Fields(resultText)),
+ }, nil
+ }
+
+ engine.RegisterHandler("ai_summary", aiHandler)
+ engine.RegisterHandler("ai_enrich", aiHandler)
+
+ // 3. Add Tags Handler
+ engine.RegisterHandler("add_tags", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
+ entryID, _ := job.Payload["entry_id"].(string)
+ if entryID == "" {
+ return nil, nil, fmt.Errorf("missing entry_id in job payload")
+ }
+
+ var tagsToAdd []string
+ if rawTags, ok := job.Payload["tags"].([]interface{}); ok {
+ for _, t := range rawTags {
+ tagsToAdd = append(tagsToAdd, fmt.Sprintf("%v", t))
+ }
+ } else if rawTagsStr, ok := job.Payload["tags"].(string); ok {
+ for _, t := range strings.Split(rawTagsStr, ",") {
+ if tr := strings.TrimSpace(t); tr != "" {
+ tagsToAdd = append(tagsToAdd, tr)
+ }
+ }
+ }
+
+ entry, err := database.GetEntry(entryID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("entry not found: %w", err)
+ }
+
+ existingTags := make(map[string]bool)
+ if entry.Metadata != nil {
+ if curr, ok := entry.Metadata["tags"].([]interface{}); ok {
+ for _, t := range curr {
+ existingTags[fmt.Sprintf("%v", t)] = true
+ }
+ }
+ }
+
+ for _, t := range tagsToAdd {
+ existingTags[t] = true
+ }
+
+ var merged []string
+ for t := range existingTags {
+ merged = append(merged, t)
+ }
+
+ patch := &models.PatchEntryRequest{
+ Metadata: map[string]interface{}{
+ "tags": merged,
+ },
+ }
+
+ return patch, map[string]interface{}{"tags": merged}, nil
+ })
+
+ // 4. Mark Read Handler
+ engine.RegisterHandler("mark_read", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
+ read := true
+ return &models.PatchEntryRequest{IsRead: &read}, map[string]interface{}{"is_read": true}, nil
+ })
+
+ // 5. Mark Starred Handler
+ engine.RegisterHandler("mark_starred", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
+ starred := true
+ return &models.PatchEntryRequest{IsStarred: &starred}, map[string]interface{}{"is_starred": true}, nil
+ })
+}
diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go
new file mode 100644
index 0000000..2e8af85
--- /dev/null
+++ b/internal/api/auth_handler.go
@@ -0,0 +1,146 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+)
+
+// RateLimiter tracks login attempts per IP to prevent brute-force attacks
+type RateLimiter struct {
+ mu sync.Mutex
+ attempts map[string][]time.Time
+ limit int
+ window time.Duration
+}
+
+func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
+ return &RateLimiter{
+ attempts: make(map[string][]time.Time),
+ limit: limit,
+ window: window,
+ }
+}
+
+func (rl *RateLimiter) Allow(key string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ now := time.Now()
+ cutoff := now.Add(-rl.window)
+
+ // Filter out expired timestamps
+ valid := make([]time.Time, 0)
+ for _, t := range rl.attempts[key] {
+ if t.After(cutoff) {
+ valid = append(valid, t)
+ }
+ }
+
+ if len(valid) >= rl.limit {
+ rl.attempts[key] = valid
+ return false
+ }
+
+ valid = append(valid, now)
+ rl.attempts[key] = valid
+ return true
+}
+
+func getClientIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ parts := strings.Split(xff, ",")
+ return strings.TrimSpace(parts[0])
+ }
+ if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
+ return strings.TrimSpace(xrip)
+ }
+ return r.RemoteAddr
+}
+
+// Auth Handlers
+func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
+ ip := getClientIP(r)
+ if !h.loginLimiter.Allow(ip) {
+ h.writeError(w, http.StatusTooManyRequests, "too many login attempts, please try again in a minute")
+ return
+ }
+
+ var req models.LoginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ token, err := h.auth.Authenticate(req.Username, req.Password)
+ if err != nil {
+ h.writeError(w, http.StatusUnauthorized, "invalid username or password")
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "rsspipe_token",
+ Value: token,
+ Path: "/",
+ Expires: time.Now().Add(30 * 24 * time.Hour),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ user, _ := h.db.GetUserByUsername(req.Username)
+ h.writeJSON(w, http.StatusOK, models.LoginResponse{
+ Token: token,
+ User: *user,
+ })
+}
+
+func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "rsspipe_token",
+ Value: "",
+ Path: "/",
+ Expires: time.Unix(0, 0),
+ HttpOnly: true,
+ })
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
+}
+
+func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
+ claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
+ if !ok {
+ h.writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ user, err := h.db.GetUserByUsername(claims.Username)
+ if err != nil {
+ h.writeError(w, http.StatusNotFound, "user not found")
+ return
+ }
+ h.writeJSON(w, http.StatusOK, user)
+}
+
+func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
+ claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
+ if !ok {
+ h.writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ var req models.ChangePasswordRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+
+ if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"})
+}
diff --git a/internal/api/entries_handler.go b/internal/api/entries_handler.go
new file mode 100644
index 0000000..fbf93e4
--- /dev/null
+++ b/internal/api/entries_handler.go
@@ -0,0 +1,360 @@
+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
+
+
+
+
+`,
+ 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))
+}
diff --git a/internal/api/feeds_handler.go b/internal/api/feeds_handler.go
new file mode 100644
index 0000000..01eb3dd
--- /dev/null
+++ b/internal/api/feeds_handler.go
@@ -0,0 +1,176 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "github.com/go-chi/chi/v5"
+)
+
+// Category Handlers
+func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
+ cats, err := h.feedService.ListCategories(r.Context())
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if cats == nil {
+ cats = []models.Category{}
+ }
+ h.writeJSON(w, http.StatusOK, cats)
+}
+
+func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Title string `json:"title"`
+ ParentID *string `json:"parent_id,omitempty"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" {
+ h.writeError(w, http.StatusBadRequest, "valid category title is required")
+ return
+ }
+
+ cat, err := h.feedService.CreateCategory(r.Context(), strings.TrimSpace(body.Title), body.ParentID)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusCreated, cat)
+}
+
+func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.feedService.DeleteCategory(r.Context(), id); err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "category deleted"})
+}
+
+// Feed Handlers
+func (h *Handler) ListFeeds(w http.ResponseWriter, r *http.Request) {
+ feeds, err := h.feedService.ListFeeds(r.Context())
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if feeds == nil {
+ feeds = []models.Feed{}
+ }
+ h.writeJSON(w, http.StatusOK, feeds)
+}
+
+func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
+ var req models.CreateFeedRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.FeedURL) == "" {
+ h.writeError(w, http.StatusBadRequest, "valid feed_url is required")
+ return
+ }
+
+ feed, err := h.feedService.CreateFeed(r.Context(), req)
+ if err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusCreated, feed)
+}
+
+func (h *Handler) UpdateFeed(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ var req models.UpdateFeedRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ feed, err := h.feedService.UpdateFeed(r.Context(), id, req)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, feed)
+}
+
+func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.feedService.DeleteFeed(r.Context(), id); err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "feed deleted"})
+}
+
+func (h *Handler) RefreshFeed(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ f, err := h.feedService.RefreshFeed(r.Context(), id)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, f)
+}
+
+func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) {
+ h.feedService.RefreshAllFeeds(r.Context())
+ h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"})
+}
+
+func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
+ stats, err := h.feedService.GetStats(r.Context())
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, stats)
+}
+
+// OPML Handlers
+func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ h.writeError(w, http.StatusBadRequest, "failed to parse multipart form")
+ return
+ }
+
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ h.writeError(w, http.StatusBadRequest, "file field is required")
+ return
+ }
+ defer func() {
+ _ = file.Close()
+ }()
+
+ data, err := io.ReadAll(file)
+ if err != nil {
+ h.writeError(w, http.StatusBadRequest, "failed to read file")
+ return
+ }
+
+ createdCount, totalParsed, err := h.feedService.ImportOPML(r.Context(), data)
+ if err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ h.writeJSON(w, http.StatusOK, map[string]interface{}{
+ "imported_count": createdCount,
+ "total_parsed": totalParsed,
+ })
+}
+
+func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) {
+ xmlData, err := h.feedService.ExportOPML(r.Context())
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="rsspipe-subscriptions.opml"`))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(xmlData)
+}
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 21ea8cf..6e81f8d 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -2,50 +2,95 @@ package api
import (
"context"
- "database/sql"
"encoding/json"
- "fmt"
- "io"
"net/http"
- "strconv"
- "strings"
- "sync"
"time"
- aiActions "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
- "git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
- "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
- "git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
- "git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
- "git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/services"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
- "github.com/go-chi/chi/v5"
)
type Handler struct {
- db *db.DB
- auth *auth.AuthService
- crawler *crawler.Crawler
- parser *parser.Parser
- pool *worker.Pool
- aiClient *ai.Client
- testRateMu sync.Mutex
- testPings []time.Time
+ db *db.DB
+ auth *auth.AuthService
+ bus *events.EventBus
+ entryService *services.EntryService
+ feedService *services.FeedService
+ jobService *services.JobService
+ settingsService *services.SettingsService
+ jobEngine *worker.JobEngine
+ aiClient *ai.Client
+ broadcaster *SSEBroadcaster
+ loginLimiter *RateLimiter
}
-func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
- return &Handler{
- db: database,
- auth: authService,
- crawler: cr,
- parser: parser.NewParser(),
- pool: pool,
- aiClient: ai.NewClient(30 * time.Second),
- testPings: []time.Time{},
+func NewHandler(
+ database *db.DB,
+ authService *auth.AuthService,
+ bus *events.EventBus,
+ entrySvc *services.EntryService,
+ feedSvc *services.FeedService,
+ jobSvc *services.JobService,
+ settingsSvc *services.SettingsService,
+ jobEngine *worker.JobEngine,
+) *Handler {
+ broadcaster := NewSSEBroadcaster()
+ limiter := NewRateLimiter(5, 1*time.Minute)
+
+ h := &Handler{
+ db: database,
+ auth: authService,
+ bus: bus,
+ entryService: entrySvc,
+ feedService: feedSvc,
+ jobService: jobSvc,
+ settingsService: settingsSvc,
+ jobEngine: jobEngine,
+ aiClient: ai.NewClient(45 * time.Second),
+ broadcaster: broadcaster,
+ loginLimiter: limiter,
}
+
+ // Wire EventBus to SSE Broadcaster
+ if bus != nil {
+ bus.Subscribe(events.EventJobProgress, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("job_progress", evt.Job)
+ return nil
+ })
+ bus.Subscribe(events.EventJobCompleted, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("job_completed", evt.Job)
+ return nil
+ })
+ bus.Subscribe(events.EventJobFailed, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("job_failed", evt.Job)
+ return nil
+ })
+ bus.Subscribe(events.EventPreferencesSync, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("preferences_updated", evt.DirectAttrs)
+ return nil
+ })
+ bus.Subscribe(events.EventPostCreated, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("post_created", evt.Entry)
+ return nil
+ })
+ bus.Subscribe(events.EventPostUpdated, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("post_updated", evt.Entry)
+ return nil
+ })
+ bus.Subscribe(events.EventVariantUpdated, func(ctx context.Context, evt events.Event) error {
+ broadcaster.Broadcast("variant_updated", map[string]interface{}{
+ "entry": evt.Entry,
+ "variant_key": evt.VariantKey,
+ })
+ return nil
+ })
+ }
+
+ return h
}
func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) {
@@ -57,1082 +102,3 @@ func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) {
func (h *Handler) writeError(w http.ResponseWriter, status int, msg string) {
h.writeJSON(w, status, map[string]string{"error": msg})
}
-
-// Auth Handlers
-func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
- var req models.LoginRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request body")
- return
- }
-
- token, err := h.auth.Authenticate(req.Username, req.Password)
- if err != nil {
- h.writeError(w, http.StatusUnauthorized, "invalid username or password")
- return
- }
-
- http.SetCookie(w, &http.Cookie{
- Name: "rsspipe_token",
- Value: token,
- Path: "/",
- Expires: time.Now().Add(30 * 24 * time.Hour),
- HttpOnly: true,
- SameSite: http.SameSiteLaxMode,
- })
-
- user, _ := h.db.GetUserByUsername(req.Username)
- h.writeJSON(w, http.StatusOK, models.LoginResponse{
- Token: token,
- User: *user,
- })
-}
-
-func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, &http.Cookie{
- Name: "rsspipe_token",
- Value: "",
- Path: "/",
- Expires: time.Unix(0, 0),
- HttpOnly: true,
- })
- h.writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
-}
-
-func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
- claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
- if !ok {
- h.writeError(w, http.StatusUnauthorized, "unauthorized")
- return
- }
- user, err := h.db.GetUserByUsername(claims.Username)
- if err != nil {
- h.writeError(w, http.StatusNotFound, "user not found")
- return
- }
- h.writeJSON(w, http.StatusOK, user)
-}
-
-func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
- claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
- if !ok {
- h.writeError(w, http.StatusUnauthorized, "unauthorized")
- return
- }
-
- var req models.ChangePasswordRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request payload")
- return
- }
-
- if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
- h.writeError(w, http.StatusBadRequest, err.Error())
- return
- }
-
- h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"})
-}
-
-// User Preferences Handlers
-func (h *Handler) GetUserPreferences(w http.ResponseWriter, r *http.Request) {
- claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
- if !ok {
- h.writeError(w, http.StatusUnauthorized, "unauthorized")
- return
- }
- user, err := h.db.GetUserByUsername(claims.Username)
- if err != nil {
- h.writeError(w, http.StatusNotFound, "user not found")
- return
- }
- h.writeJSON(w, http.StatusOK, user.Preferences)
-}
-
-func (h *Handler) UpdateUserPreferences(w http.ResponseWriter, r *http.Request) {
- claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
- if !ok {
- h.writeError(w, http.StatusUnauthorized, "unauthorized")
- return
- }
- user, err := h.db.GetUserByUsername(claims.Username)
- if err != nil {
- h.writeError(w, http.StatusNotFound, "user not found")
- return
- }
- var newPrefs map[string]interface{}
- if err := json.NewDecoder(r.Body).Decode(&newPrefs); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request payload")
- return
- }
- if user.Preferences == nil {
- user.Preferences = make(map[string]interface{})
- }
- for k, v := range newPrefs {
- user.Preferences[k] = v
- }
- if err := h.db.UpdateUserPreferences(user.ID, user.Preferences); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, user.Preferences)
-}
-
-// Stats Handler
-func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
- stats, err := h.db.GetStats()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, stats)
-}
-
-// Category Handlers
-func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
- cats, err := h.db.ListCategories()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- if cats == nil {
- cats = []models.Category{}
- }
- h.writeJSON(w, http.StatusOK, cats)
-}
-
-func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
- var body struct {
- Title string `json:"title"`
- ParentID *string `json:"parent_id,omitempty"`
- }
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" {
- h.writeError(w, http.StatusBadRequest, "valid category title is required")
- return
- }
-
- cat, err := h.db.CreateCategory(strings.TrimSpace(body.Title), body.ParentID)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusCreated, cat)
-}
-
-func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- if err := h.db.DeleteCategory(id); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, map[string]string{"message": "category deleted"})
-}
-
-// Feed Handlers
-func (h *Handler) ListFeeds(w http.ResponseWriter, r *http.Request) {
- feeds, err := h.db.ListFeeds()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- if feeds == nil {
- feeds = []models.Feed{}
- }
- h.writeJSON(w, http.StatusOK, feeds)
-}
-
-func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
- var req models.CreateFeedRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.FeedURL) == "" {
- h.writeError(w, http.StatusBadRequest, "valid feed_url is required")
- return
- }
-
- req.FeedURL = strings.TrimSpace(req.FeedURL)
-
- // Check if already subscribed
- if existing, _ := h.db.GetFeedByURL(req.FeedURL); existing != nil {
- h.writeError(w, http.StatusConflict, "feed is already subscribed")
- return
- }
-
- // Fetch to test and discover title/site URL
- res, err := h.parser.FetchFeed(r.Context(), req.FeedURL, "", "")
- if err != nil {
- h.writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to fetch feed: %v", err))
- return
- }
-
- title := res.Title
- if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
- title = strings.TrimSpace(*req.Title)
- }
- iconURL := ""
- if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
- iconURL = strings.TrimSpace(*req.IconURL)
- }
-
- feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, iconURL, req.CategoryID)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
-
- // Insert parsed entries
- if len(res.Entries) > 0 {
- _, _ = h.db.UpsertFeedEntries(feed.ID, res.Entries)
- _ = h.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "")
- }
-
- // Retrieve updated feed with counts
- fullFeed, err := h.db.GetFeed(feed.ID)
- if err != nil {
- h.writeJSON(w, http.StatusCreated, feed)
- return
- }
- h.writeJSON(w, http.StatusCreated, fullFeed)
-}
-
-func (h *Handler) UpdateFeed(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- var req models.UpdateFeedRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request body")
- return
- }
-
- if err := h.db.UpdateFeed(id, req); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
-
- fullFeed, err := h.db.GetFeed(id)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, fullFeed)
-}
-
-func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- if err := h.db.DeleteFeed(id); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, map[string]string{"message": "feed deleted"})
-}
-
-func (h *Handler) RefreshFeed(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- if err := h.crawler.RefreshSingle(r.Context(), id); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- f, _ := h.db.GetFeed(id)
- h.writeJSON(w, http.StatusOK, f)
-}
-
-func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) {
- go h.crawler.RefreshAll(context.Background())
- h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"})
-}
-
-// 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.db.ListEntries(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) 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
- }
-
- if err := h.db.UpdateEntryRead(id, body.IsRead); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": body.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
- }
-
- if err := h.db.UpdateEntryStarred(id, body.IsStarred); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": body.IsStarred})
-}
-
-func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
- var body models.MarkAllReadRequest
- _ = json.NewDecoder(r.Body).Decode(&body)
-
- if err := h.db.MarkAllRead(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"})
-}
-
-// OPML Handlers
-func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseMultipartForm(10 << 20); err != nil { // 10MB max
- h.writeError(w, http.StatusBadRequest, "failed to parse multipart form")
- return
- }
-
- file, _, err := r.FormFile("file")
- if err != nil {
- h.writeError(w, http.StatusBadRequest, "file field is required")
- return
- }
- defer func() {
- _ = file.Close()
- }()
-
- data, err := io.ReadAll(file)
- if err != nil {
- h.writeError(w, http.StatusBadRequest, "failed to read file")
- return
- }
-
- importedFeeds, err := opml.ParseOPML(data)
- if err != nil {
- h.writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid OPML XML: %v", err))
- return
- }
-
- categoryCache := make(map[string]string) // title -> id
- cats, _ := h.db.ListCategories()
- for _, c := range cats {
- categoryCache[c.Title] = c.ID
- }
-
- createdCount := 0
- for _, item := range importedFeeds {
- if item.FeedURL == "" {
- continue
- }
- if existing, _ := h.db.GetFeedByURL(item.FeedURL); existing != nil {
- continue
- }
-
- var catID *string
- if item.CategoryTitle != "" {
- if cid, ok := categoryCache[item.CategoryTitle]; ok {
- catID = &cid
- } else {
- newCat, err := h.db.CreateCategory(item.CategoryTitle, nil)
- if err == nil {
- categoryCache[item.CategoryTitle] = newCat.ID
- catID = &newCat.ID
- }
- }
- }
-
- title := item.Title
- if title == "" {
- title = item.FeedURL
- }
-
- _, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
- if err == nil {
- createdCount++
- }
- }
-
- // Trigger background crawler for newly imported feeds
- go h.crawler.RefreshAll(context.Background())
-
- h.writeJSON(w, http.StatusOK, map[string]interface{}{
- "imported_count": createdCount,
- "total_parsed": len(importedFeeds),
- })
-}
-
-func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) {
- feeds, err := h.db.ListFeeds()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- cats, err := h.db.ListCategories()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
-
- xmlData, err := opml.GenerateOPML(feeds, cats)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
-
- w.Header().Set("Content-Type", "application/xml; charset=utf-8")
- w.Header().Set("Content-Disposition", `attachment; filename="rsspipe-subscriptions.opml"`)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(xmlData)
-}
-
-// Public Share handler (/share/.html)
-func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
- uuidStr := chi.URLParam(r, "uuid")
- uuidStr = strings.TrimSuffix(uuidStr, ".html")
-
- var e models.FeedEntry
- var fTitle, fSiteURL, author, rawContent, metaStr, shareUUID sql.NullString
- query := `
- SELECT
- e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
- e.guid, e.url, e.title, e.author, e.content, e.raw_content,
- e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
- FROM feed_entries e
- JOIN feeds f ON f.id = e.feed_id
- WHERE e.share_uuid = ? OR e.id = ?
- `
- err := h.db.QueryRow(query, uuidStr, uuidStr).Scan(
- &e.ID, &e.FeedID, &fTitle, &fSiteURL,
- &e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
- &e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
- )
- if err != nil {
- http.Error(w, "Shared article not found", http.StatusNotFound)
- return
- }
- if fTitle.Valid {
- e.FeedTitle = fTitle.String
- }
- if fSiteURL.Valid {
- e.FeedSiteURL = fSiteURL.String
- }
- if author.Valid {
- e.Author = author.String
- }
-
- // Render standalone styled HTML
- html := fmt.Sprintf(`
-
-
-
-
- %s - RSSPipe
-
-
-
-
-
- %s
- •
- %s
- %s
-
-
-
- %s
-
-
-
-
-`,
- e.Title,
- e.FeedTitle,
- e.PublishedAt.Format("Jan 02, 2006 15:04"),
- func() string {
- if e.Author != "" {
- return "•By " + e.Author + ""
- }
- return ""
- }(),
- e.URL,
- e.Title,
- e.Content,
- )
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _, _ = w.Write([]byte(html))
-}
-
-// GetEntry returns a single entry by ID with its loaded variants
-func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- entry, err := h.db.GetEntry(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)
-}
-
-// ListEntryVariants returns all variants for a given entry ID
-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)
-}
-
-// GetVariantBlobStream streams the audio/binary file for a variant with HTTP 206 Partial Content support
-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)
-}
-
-// ScrapeEntry manually triggers readability full text scraping and saves to entry_variants
-func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- entry, err := h.db.GetEntry(id)
- if err != nil {
- h.writeError(w, http.StatusNotFound, "entry not found")
- return
- }
-
- if h.pool == nil {
- h.writeError(w, http.StatusInternalServerError, "worker pool not configured")
- return
- }
-
- res, err := h.pool.ExecuteDirect(r.Context(), "readability_scrape", *entry, nil)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err))
- return
- }
-
- if res != nil && res.ResultVariant != nil {
- _ = h.db.SetVariant(*res.ResultVariant)
- }
-
- // Reload entry with newly saved variant
- updatedEntry, err := h.db.GetEntry(id)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
-
- h.writeJSON(w, http.StatusOK, updatedEntry)
-}
-
-// ToggleShareEntry toggles public sharing for an entry and returns the updated state
-func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- shareUUID, err := h.db.ToggleShareEntry(id)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- entry, _ := h.db.GetEntry(id)
- h.writeJSON(w, http.StatusOK, map[string]interface{}{
- "shared": shareUUID != nil,
- "share_uuid": shareUUID,
- "entry": entry,
- })
-}
-
-// GenerateAISummary on-demand generates an AI summary for a single article
-func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- entry, err := h.db.GetEntry(id)
- if err != nil {
- h.writeError(w, http.StatusNotFound, "entry not found")
- return
- }
-
- cfg := aiActions.LoadConfig(h.db)
- if !cfg.Enabled {
- h.writeError(w, http.StatusBadRequest, "AI is not enabled in settings")
- return
- }
- if cfg.APIKey == "" && cfg.Provider != "ollama" {
- h.writeError(w, http.StatusBadRequest, "AI API key is not configured in settings")
- return
- }
-
- prompt := "Summarize the key points of this article clearly with 3 concise bullet points and a brief takeaway."
- resultText, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, prompt, *entry)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, "AI generation failed: "+err.Error())
- return
- }
-
- variant := models.EntryVariant{
- EntryID: entry.ID,
- VariantKey: "ai_summary",
- ContentType: "text",
- TextValue: resultText,
- Metadata: map[string]interface{}{
- "model": cfg.Model,
- },
- }
- if err := h.db.SetVariant(variant); err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to save AI summary: "+err.Error())
- return
- }
-
- updated, err := h.db.GetEntry(id)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, updated)
-}
-
-// Rules API Handlers
-func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
- rules, err := h.db.ListRules()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- if rules == nil {
- rules = []models.Rule{}
- }
- h.writeJSON(w, http.StatusOK, rules)
-}
-
-type CreateRuleRequest struct {
- Name string `json:"name"`
- Trigger string `json:"trigger"`
- MatchMode string `json:"match_mode"`
- Conditions []models.RuleCondition `json:"conditions"`
- Actions []models.RuleAction `json:"actions"`
-}
-
-func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
- var body CreateRuleRequest
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request body")
- return
- }
- if strings.TrimSpace(body.Name) == "" {
- h.writeError(w, http.StatusBadRequest, "rule name is required")
- return
- }
- if strings.TrimSpace(body.Trigger) == "" {
- body.Trigger = "on_post_created"
- }
- if err := rules.ValidateRuleSafety(models.Rule{
- Trigger: body.Trigger,
- Conditions: body.Conditions,
- Actions: body.Actions,
- }); err != nil {
- h.writeError(w, http.StatusBadRequest, err.Error())
- return
- }
-
- rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusCreated, rule)
-}
-
-type UpdateRuleRequest struct {
- Name string `json:"name"`
- Trigger string `json:"trigger"`
- MatchMode string `json:"match_mode"`
- Conditions []models.RuleCondition `json:"conditions"`
- Actions []models.RuleAction `json:"actions"`
- IsActive bool `json:"is_active"`
-}
-
-func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- var body UpdateRuleRequest
- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request body")
- return
- }
-
- if err := rules.ValidateRuleSafety(models.Rule{
- Trigger: body.Trigger,
- Conditions: body.Conditions,
- Actions: body.Actions,
- }); err != nil {
- h.writeError(w, http.StatusBadRequest, err.Error())
- return
- }
-
- rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, rule)
-}
-
-func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- if err := h.db.DeleteRule(id); err != nil {
- h.writeError(w, http.StatusInternalServerError, err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
-}
-
-func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
- id := chi.URLParam(r, "id")
- rule, err := h.db.GetRule(id)
- if err != nil {
- h.writeError(w, http.StatusNotFound, "rule not found")
- return
- }
-
- evaluator := rules.NewEvaluator()
- matchedCount := 0
- queuedJobs := 0
-
- // Paginate through ALL entries in batches of 500
- batchSize := 500
- offset := 0
- for {
- entries, total, err := h.db.ListEntries(db.EntryFilter{
- View: "all",
- Limit: batchSize,
- Offset: offset,
- })
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to query entries: "+err.Error())
- return
- }
-
- for _, entry := range entries {
- if evaluator.Evaluate(*rule, entry) {
- matchedCount++
- for _, action := range rule.Actions {
- h.pool.Dispatch(r.Context(), action, entry, 0)
- queuedJobs++
- }
- }
- }
-
- offset += batchSize
- if offset >= total || len(entries) == 0 {
- break
- }
- }
-
- h.writeJSON(w, http.StatusOK, map[string]interface{}{
- "message": fmt.Sprintf("Rule applied to %d matching post(s). %d action(s) queued.", matchedCount, queuedJobs),
- "matched_count": matchedCount,
- "queued_jobs": queuedJobs,
- })
-}
-
-// AI Settings Handlers
-type aiSettingsResponse struct {
- Enabled bool `json:"enabled"`
- Provider string `json:"provider"`
- BaseURL string `json:"base_url"`
- APIKey string `json:"api_key"`
- Model string `json:"model"`
- Temperature float64 `json:"temperature"`
- MaxTokens int `json:"max_tokens"`
- Prompts []ai.PromptTemplate `json:"prompts"`
-}
-
-func maskAPIKey(key string) string {
- if len(key) == 0 {
- return ""
- }
- if len(key) <= 6 {
- return "••••••"
- }
- return key[:3] + "••••••••" + key[len(key)-4:]
-}
-
-func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
- settings, err := h.db.GetSettingsMap()
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to load settings: "+err.Error())
- return
- }
-
- enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
- provider := settings["ai_provider"]
- if provider == "" {
- provider = "openai"
- }
- baseURL := settings["ai_base_url"]
- if baseURL == "" {
- baseURL = "https://api.openai.com/v1"
- }
- model := settings["ai_model"]
- if model == "" {
- model = "gpt-4o-mini"
- }
- temp, _ := strconv.ParseFloat(settings["ai_temperature"], 64)
- if temp <= 0 {
- temp = 0.3
- }
- maxTokens, _ := strconv.Atoi(settings["ai_max_tokens"])
- if maxTokens <= 0 {
- maxTokens = 1000
- }
-
- var prompts []ai.PromptTemplate
- if promptsJSON := settings["ai_prompts"]; promptsJSON != "" {
- _ = json.Unmarshal([]byte(promptsJSON), &prompts)
- }
- if len(prompts) == 0 {
- prompts = ai.DefaultPromptTemplates()
- }
-
- h.writeJSON(w, http.StatusOK, aiSettingsResponse{
- Enabled: enabled,
- Provider: provider,
- BaseURL: baseURL,
- APIKey: maskAPIKey(settings["ai_api_key"]),
- Model: model,
- Temperature: temp,
- MaxTokens: maxTokens,
- Prompts: prompts,
- })
-}
-
-func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
- var req struct {
- Enabled *bool `json:"enabled"`
- Provider string `json:"provider"`
- BaseURL string `json:"base_url"`
- APIKey string `json:"api_key"`
- Model string `json:"model"`
- Temperature float64 `json:"temperature"`
- MaxTokens int `json:"max_tokens"`
- Prompts []ai.PromptTemplate `json:"prompts"`
- }
-
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid JSON request")
- return
- }
-
- if req.Enabled != nil {
- val := "false"
- if *req.Enabled {
- val = "true"
- }
- _ = h.db.SetSetting("ai_enabled", val)
- }
- if req.Provider != "" {
- _ = h.db.SetSetting("ai_provider", req.Provider)
- }
- if req.BaseURL != "" {
- _ = h.db.SetSetting("ai_base_url", req.BaseURL)
- }
- if req.Model != "" {
- _ = h.db.SetSetting("ai_model", req.Model)
- }
- if req.Temperature > 0 {
- _ = h.db.SetSetting("ai_temperature", fmt.Sprintf("%.2f", req.Temperature))
- }
- if req.MaxTokens > 0 {
- _ = h.db.SetSetting("ai_max_tokens", strconv.Itoa(req.MaxTokens))
- }
-
- // Update API key only if not masked with bullets
- if req.APIKey != "" && !strings.Contains(req.APIKey, "••••") {
- _ = h.db.SetSetting("ai_api_key", strings.TrimSpace(req.APIKey))
- }
-
- if len(req.Prompts) > 0 {
- promptsBytes, _ := json.Marshal(req.Prompts)
- _ = h.db.SetSetting("ai_prompts", string(promptsBytes))
- }
-
- h.GetAISettings(w, r)
-}
-
-// TestAIConnection checks LLM connectivity with strict rate limiting & token caps
-func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
- // Rate limiting: Max 5 test pings per minute to prevent token exhaustion
- h.testRateMu.Lock()
- now := time.Now()
- var recentPings []time.Time
- for _, t := range h.testPings {
- if now.Sub(t) < time.Minute {
- recentPings = append(recentPings, t)
- }
- }
- if len(recentPings) >= 5 {
- h.testRateMu.Unlock()
- h.writeError(w, http.StatusTooManyRequests, "Rate limit exceeded. Maximum 5 connection tests per minute.")
- return
- }
- h.testPings = append(recentPings, now)
- h.testRateMu.Unlock()
-
- var req struct {
- Provider string `json:"provider"`
- BaseURL string `json:"base_url"`
- APIKey string `json:"api_key"`
- Model string `json:"model"`
- }
- _ = json.NewDecoder(r.Body).Decode(&req)
-
- settings, _ := h.db.GetSettingsMap()
-
- apiKey := req.APIKey
- if apiKey == "" || strings.Contains(apiKey, "••••") {
- apiKey = settings["ai_api_key"]
- }
- baseURL := req.BaseURL
- if baseURL == "" {
- baseURL = settings["ai_base_url"]
- }
- if baseURL == "" {
- baseURL = "https://api.openai.com/v1"
- }
- model := req.Model
- if model == "" {
- model = settings["ai_model"]
- }
- if model == "" {
- model = "gpt-4o-mini"
- }
-
- cfg := ai.Config{
- Enabled: true,
- Provider: req.Provider,
- BaseURL: baseURL,
- APIKey: apiKey,
- Model: model,
- }
-
- res, err := h.aiClient.TestConnection(r.Context(), cfg)
- if err != nil {
- h.writeJSON(w, http.StatusOK, res)
- return
- }
-
- h.writeJSON(w, http.StatusOK, res)
-}
-
-// Search History Handlers
-func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query().Get("q")
- limitStr := r.URL.Query().Get("limit")
- limit := 10
- if limitStr != "" {
- if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
- limit = l
- }
- }
-
- history, err := h.db.GetSearchHistory(q, limit)
- if err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to get search history: "+err.Error())
- return
- }
- if history == nil {
- history = []models.SearchHistoryItem{}
- }
- h.writeJSON(w, http.StatusOK, history)
-}
-
-func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
- var req struct {
- Keyword string `json:"keyword"`
- }
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- h.writeError(w, http.StatusBadRequest, "invalid request body")
- return
- }
- if strings.TrimSpace(req.Keyword) == "" {
- h.writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"})
- return
- }
-
- if err := h.db.RecordSearch(req.Keyword); err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to record search: "+err.Error())
- return
- }
- h.writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"})
-}
-
-func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
- keyword := r.URL.Query().Get("keyword")
- if keyword != "" {
- if err := h.db.DeleteSearchHistory(keyword); err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to delete search history: "+err.Error())
- return
- }
- } else {
- if err := h.db.ClearSearchHistory(); err != nil {
- h.writeError(w, http.StatusInternalServerError, "failed to clear search history: "+err.Error())
- return
- }
- }
- h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
-}
-
diff --git a/internal/api/jobs_handler.go b/internal/api/jobs_handler.go
new file mode 100644
index 0000000..5b008e0
--- /dev/null
+++ b/internal/api/jobs_handler.go
@@ -0,0 +1,70 @@
+package api
+
+import (
+ "database/sql"
+ "net/http"
+ "strconv"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "github.com/go-chi/chi/v5"
+)
+
+func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit, _ := strconv.Atoi(q.Get("limit"))
+ offset, _ := strconv.Atoi(q.Get("offset"))
+
+ filter := models.JobFilter{
+ Type: q.Get("type"),
+ Status: q.Get("status"),
+ Limit: limit,
+ Offset: offset,
+ }
+
+ jobs, err := h.jobService.ListJobs(r.Context(), filter)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if jobs == nil {
+ jobs = []models.Job{}
+ }
+
+ h.writeJSON(w, http.StatusOK, map[string]interface{}{
+ "jobs": jobs,
+ "limit": limit,
+ "offset": offset,
+ })
+}
+
+func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ job, err := h.jobService.GetJob(r.Context(), id)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ h.writeError(w, http.StatusNotFound, "job not found")
+ return
+ }
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, job)
+}
+
+func (h *Handler) CancelJob(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.jobService.CancelJob(r.Context(), id); err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "job cancelled"})
+}
+
+func (h *Handler) RetryJob(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.jobService.RetryJob(r.Context(), id); err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "job queued for retry"})
+}
diff --git a/internal/api/rules_handler.go b/internal/api/rules_handler.go
new file mode 100644
index 0000000..ab37b10
--- /dev/null
+++ b/internal/api/rules_handler.go
@@ -0,0 +1,131 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
+ "github.com/go-chi/chi/v5"
+)
+
+// Rules API Handlers
+func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
+ rulesList, err := h.db.ListRules()
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if rulesList == nil {
+ rulesList = []models.Rule{}
+ }
+ h.writeJSON(w, http.StatusOK, rulesList)
+}
+
+func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
+ var body models.Rule
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid rule payload")
+ return
+ }
+
+ body.Name = strings.TrimSpace(body.Name)
+ if body.Name == "" {
+ h.writeError(w, http.StatusBadRequest, "rule name is required")
+ return
+ }
+ if body.Trigger == "" {
+ body.Trigger = "on_update"
+ }
+ if body.MatchMode == "" {
+ body.MatchMode = "all"
+ }
+
+ if err := rules.ValidateRuleSafety(body); err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusCreated, rule)
+}
+
+func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ var body models.Rule
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid rule payload")
+ return
+ }
+
+ body.Name = strings.TrimSpace(body.Name)
+ if body.Name == "" {
+ h.writeError(w, http.StatusBadRequest, "rule name is required")
+ return
+ }
+
+ if err := rules.ValidateRuleSafety(body); err != nil {
+ h.writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, rule)
+}
+
+func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if err := h.db.DeleteRule(id); err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
+}
+
+func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ rule, err := h.db.GetRule(id)
+ if err != nil {
+ h.writeError(w, http.StatusNotFound, "rule not found")
+ return
+ }
+
+ entries, _, err := h.db.ListEntries(db.EntryFilter{Limit: 200})
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ eval := rules.NewEvaluator()
+ matchedCount := 0
+
+ for _, entry := range entries {
+ if eval.Evaluate(*rule, entry) {
+ matchedCount++
+ if h.bus != nil {
+ h.bus.Publish(r.Context(), events.Event{
+ Type: events.EventManualTrigger,
+ Entry: entry,
+ Depth: 0,
+ })
+ }
+ }
+ }
+
+ h.writeJSON(w, http.StatusOK, map[string]interface{}{
+ "message": "rule applied to matching articles",
+ "matched_count": matchedCount,
+ "queued_jobs": matchedCount * len(rule.Actions),
+ })
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 9443ae9..63c8a72 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -20,10 +20,10 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
- // CORS configuration for local development & PWA
+ // CORS configuration: Restricted to localhost dev ports and same-origin
r.Use(cors.Handler(cors.Options{
- AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080", "*"},
- AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
+ AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
@@ -43,6 +43,10 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
r.Group(func(r chi.Router) {
r.Use(authService.Middleware)
+ // Real-time Event Stream (K8s Watch pattern via SSE)
+ r.Get("/events/stream", h.StreamEvents)
+
+ // Auth & Profile
r.Get("/auth/me", h.GetMe)
r.Put("/auth/password", h.UpdatePassword)
r.Get("/user/preferences", h.GetUserPreferences)
@@ -65,15 +69,22 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
// Entries
r.Get("/entries", h.ListEntries)
r.Get("/entries/{id}", h.GetEntry)
+ r.Patch("/entries/{id}", h.PatchEntry)
r.Put("/entries/{id}/read", h.UpdateEntryRead)
r.Put("/entries/{id}/star", h.UpdateEntryStar)
r.Post("/entries/{id}/share", h.ToggleShareEntry)
r.Post("/entries/{id}/ai-summary", h.GenerateAISummary)
- r.Post("/entries/mark-all-read", h.MarkAllRead)
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
+ r.Post("/entries/mark-all-read", h.MarkAllRead)
r.Get("/entries/{id}/variants", h.ListEntryVariants)
r.Get("/entries/{id}/variants/{key}/stream", h.GetVariantBlobStream)
+ // Task Queue / Background Jobs
+ r.Get("/jobs", h.ListJobs)
+ r.Get("/jobs/{id}", h.GetJob)
+ r.Post("/jobs/{id}/cancel", h.CancelJob)
+ r.Post("/jobs/{id}/retry", h.RetryJob)
+
// Rules
r.Get("/rules", h.ListRules)
r.Post("/rules", h.CreateRule)
diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go
new file mode 100644
index 0000000..4f2d31e
--- /dev/null
+++ b/internal/api/settings_handler.go
@@ -0,0 +1,159 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+)
+
+// User Preferences Handlers
+func (h *Handler) GetUserPreferences(w http.ResponseWriter, r *http.Request) {
+ claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
+ if !ok {
+ h.writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ prefs, err := h.settingsService.GetPreferences(r.Context(), claims.Username)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, prefs)
+}
+
+func (h *Handler) UpdateUserPreferences(w http.ResponseWriter, r *http.Request) {
+ claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
+ if !ok {
+ h.writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ var newPrefs map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&newPrefs); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+
+ updated, err := h.settingsService.UpdatePreferences(r.Context(), claims.Username, newPrefs)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, updated)
+}
+
+// AI Settings Handlers
+func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
+ cfg, err := h.settingsService.GetAISettings(r.Context())
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
+ var body models.AISettings
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid body")
+ return
+ }
+
+ updated, err := h.settingsService.UpdateAISettings(r.Context(), body)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, updated)
+}
+
+func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
+ var body models.AISettings
+ _ = json.NewDecoder(r.Body).Decode(&body)
+
+ cfg := ai.Config{
+ Enabled: body.Enabled,
+ Provider: body.Provider,
+ BaseURL: body.BaseURL,
+ APIKey: body.APIKey,
+ Model: body.Model,
+ }
+
+ if cfg.Provider == "" {
+ cfg.Provider = "openai"
+ }
+ if cfg.BaseURL == "" {
+ cfg.BaseURL = "https://api.openai.com/v1"
+ }
+
+ start := time.Now()
+ testEntry := models.FeedEntry{
+ Title: "RSSPipe Connection Test",
+ Content: "This is a quick test to verify connection to your AI provider.",
+ }
+
+ res, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, "Respond with 'Connection successful' only.", testEntry)
+ latency := time.Since(start).Milliseconds()
+
+ if err != nil {
+ h.writeJSON(w, http.StatusOK, map[string]interface{}{
+ "success": false,
+ "message": err.Error(),
+ "latency_ms": latency,
+ "model": cfg.Model,
+ })
+ return
+ }
+
+ h.writeJSON(w, http.StatusOK, map[string]interface{}{
+ "success": true,
+ "message": strings.TrimSpace(res),
+ "latency_ms": latency,
+ "model": cfg.Model,
+ })
+}
+
+// Search History Handlers
+func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query().Get("q")
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ items, err := h.db.GetSearchHistory(q, limit)
+ if err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if items == nil {
+ items = []models.SearchHistoryItem{}
+ }
+ h.writeJSON(w, http.StatusOK, items)
+}
+
+func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Keyword string `json:"keyword"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Keyword) == "" {
+ h.writeError(w, http.StatusBadRequest, "keyword is required")
+ return
+ }
+ if err := h.db.RecordSearch(body.Keyword); err != nil {
+ h.writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "recorded"})
+}
+
+func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
+ kw := r.URL.Query().Get("keyword")
+ if kw != "" {
+ _ = h.db.DeleteSearchHistory(kw)
+ } else {
+ _ = h.db.ClearSearchHistory()
+ }
+ h.writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
+}
diff --git a/internal/api/sse_handler.go b/internal/api/sse_handler.go
new file mode 100644
index 0000000..84a442d
--- /dev/null
+++ b/internal/api/sse_handler.go
@@ -0,0 +1,104 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type SSEBroadcaster struct {
+ mu sync.RWMutex
+ clients map[chan []byte]struct{}
+}
+
+func NewSSEBroadcaster() *SSEBroadcaster {
+ return &SSEBroadcaster{
+ clients: make(map[chan []byte]struct{}),
+ }
+}
+
+func (b *SSEBroadcaster) Subscribe() chan []byte {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ ch := make(chan []byte, 32)
+ b.clients[ch] = struct{}{}
+ return ch
+}
+
+func (b *SSEBroadcaster) Unsubscribe(ch chan []byte) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if _, ok := b.clients[ch]; ok {
+ delete(b.clients, ch)
+ close(ch)
+ }
+}
+
+func (b *SSEBroadcaster) Broadcast(event string, data interface{}) {
+ payload, err := json.Marshal(data)
+ if err != nil {
+ return
+ }
+
+ msg := fmt.Sprintf("event: %s\ndata: %s\n\n", event, string(payload))
+ msgBytes := []byte(msg)
+
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ for ch := range b.clients {
+ select {
+ case ch <- msgBytes:
+ default:
+ // Non-blocking drop if client buffer is full
+ }
+ }
+}
+
+// StreamEvents handles GET /api/events/stream
+func (h *Handler) StreamEvents(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+ w.WriteHeader(http.StatusOK)
+
+ // Send initial connection packet
+ initialMsg := fmt.Sprintf("event: connected\ndata: {\"status\":\"connected\",\"time\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339))
+ _, _ = w.Write([]byte(initialMsg))
+ flusher.Flush()
+
+ clientCh := h.broadcaster.Subscribe()
+ defer h.broadcaster.Unsubscribe(clientCh)
+
+ heartbeat := time.NewTicker(25 * time.Second)
+ defer heartbeat.Stop()
+
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case <-heartbeat.C:
+ pingMsg := fmt.Sprintf("event: ping\ndata: {\"time\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339))
+ if _, err := w.Write([]byte(pingMsg)); err != nil {
+ return
+ }
+ flusher.Flush()
+ case msg, ok := <-clientCh:
+ if !ok {
+ return
+ }
+ if _, err := w.Write(msg); err != nil {
+ return
+ }
+ flusher.Flush()
+ }
+ }
+}
diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go
index b750672..1c3c8cc 100644
--- a/internal/crawler/crawler.go
+++ b/internal/crawler/crawler.go
@@ -202,15 +202,27 @@ func (c *Crawler) fetchSingleFeed(ctx context.Context, f models.Feed) error {
zap.Int("new_entries", len(newEntries)),
)
- // Publish on_post_created events for newly inserted entries
+ // Publish on_post_created and on_entry_transition events for newly inserted entries
if c.bus != nil {
for _, entry := range newEntries {
entry.FeedTitle = f.Title
entry.FeedSiteURL = f.SiteURL
+ diff := &models.EntryDiff{
+ FieldsChanged: []string{"id", "title", "content", "url"},
+ }
c.bus.Publish(ctx, events.Event{
- Type: events.EventPostCreated,
- Entry: entry,
- Depth: 0,
+ Type: events.EventPostCreated,
+ Entry: entry,
+ NewEntry: &entry,
+ Diff: diff,
+ Depth: 0,
+ })
+ c.bus.Publish(ctx, events.Event{
+ Type: events.EventEntryTransition,
+ Entry: entry,
+ NewEntry: &entry,
+ Diff: diff,
+ Depth: 0,
})
}
}
diff --git a/internal/db/db.go b/internal/db/db.go
index dc78498..13cd8d6 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -25,7 +25,8 @@ func InitDB(dbPath string) (*DB, error) {
return nil, fmt.Errorf("failed to create db directory: %w", err)
}
- db, err := sql.Open("sqlite", dbPath)
+ dsn := fmt.Sprintf("%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=cache_size(-20000)", dbPath)
+ db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
}
@@ -34,18 +35,6 @@ func InitDB(dbPath string) (*DB, error) {
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
- pragmas := []string{
- "PRAGMA journal_mode = WAL;",
- "PRAGMA busy_timeout = 5000;",
- "PRAGMA synchronous = NORMAL;",
- "PRAGMA foreign_keys = ON;",
- }
- for _, p := range pragmas {
- if _, err := db.Exec(p); err != nil {
- return nil, fmt.Errorf("failed to set pragma %s: %w", p, err)
- }
- }
-
database := &DB{DB: db}
if err := database.migrate(); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
@@ -153,6 +142,24 @@ func (d *DB) migrate() error {
search_count INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_search_history_time ON search_history(last_searched_at DESC);
+
+ CREATE TABLE IF NOT EXISTS jobs (
+ id TEXT PRIMARY KEY,
+ type TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ status TEXT NOT NULL,
+ progress INTEGER DEFAULT 0,
+ result TEXT DEFAULT '{}',
+ error_message TEXT DEFAULT '',
+ attempts INTEGER DEFAULT 0,
+ max_attempts INTEGER DEFAULT 3,
+ idempotency_key TEXT UNIQUE,
+ run_at DATETIME NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at);
+ CREATE INDEX IF NOT EXISTS idx_jobs_idempotency ON jobs(idempotency_key);
`
if _, err := d.Exec(schema); err != nil {
return err
@@ -848,6 +855,15 @@ func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
return err
}
+func (d *DB) UpdateEntryMetadata(id string, metadata map[string]interface{}) error {
+ metaBytes, err := json.Marshal(metadata)
+ if err != nil {
+ return err
+ }
+ _, err = d.Exec("UPDATE feed_entries SET metadata = ? WHERE id = ?", string(metaBytes), id)
+ return err
+}
+
func (d *DB) ToggleShareEntry(id string) (*string, error) {
entry, err := d.GetEntry(id)
if err != nil {
@@ -1291,3 +1307,312 @@ func (d *DB) ClearSearchHistory() error {
_, err := d.Exec("DELETE FROM search_history")
return err
}
+
+// Job Queue Operations
+func (d *DB) CreateJob(job models.Job) (*models.Job, error) {
+ if job.ID == "" {
+ job.ID = uuid.New().String()
+ }
+ now := time.Now().UTC()
+ if job.CreatedAt.IsZero() {
+ job.CreatedAt = now
+ }
+ job.UpdatedAt = now
+ if job.RunAt.IsZero() {
+ job.RunAt = now
+ }
+ if job.Status == "" {
+ job.Status = "pending"
+ }
+ if job.MaxAttempts <= 0 {
+ job.MaxAttempts = 3
+ }
+
+ payloadBytes, err := json.Marshal(job.Payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal job payload: %w", err)
+ }
+ resultBytes, _ := json.Marshal(job.Result)
+ if len(resultBytes) == 0 {
+ resultBytes = []byte("{}")
+ }
+
+ var idemKey sql.NullString
+ if job.IdempotencyKey != "" {
+ idemKey = sql.NullString{String: job.IdempotencyKey, Valid: true}
+ }
+
+ query := `
+ INSERT INTO jobs (
+ id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `
+ _, err = d.Exec(
+ query,
+ job.ID, job.Type, string(payloadBytes), job.Status, job.Progress,
+ string(resultBytes), job.ErrorMessage, job.Attempts, job.MaxAttempts,
+ idemKey, job.RunAt, job.CreatedAt, job.UpdatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &job, nil
+}
+
+func (d *DB) GetJob(id string) (*models.Job, error) {
+ query := `
+ SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
+ FROM jobs
+ WHERE id = ?
+ `
+ var j models.Job
+ var payloadStr, resultStr, errorMsg string
+ var idemKey sql.NullString
+
+ err := d.QueryRow(query, id).Scan(
+ &j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
+ &errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ _ = json.Unmarshal([]byte(payloadStr), &j.Payload)
+ _ = json.Unmarshal([]byte(resultStr), &j.Result)
+ j.ErrorMessage = errorMsg
+ if idemKey.Valid {
+ j.IdempotencyKey = idemKey.String
+ }
+ return &j, nil
+}
+
+func (d *DB) GetJobByIdempotencyKey(key string) (*models.Job, error) {
+ if key == "" {
+ return nil, sql.ErrNoRows
+ }
+ query := `
+ SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
+ FROM jobs
+ WHERE idempotency_key = ?
+ `
+ var j models.Job
+ var payloadStr, resultStr, errorMsg string
+ var idemKey sql.NullString
+
+ err := d.QueryRow(query, key).Scan(
+ &j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
+ &errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ _ = json.Unmarshal([]byte(payloadStr), &j.Payload)
+ _ = json.Unmarshal([]byte(resultStr), &j.Result)
+ j.ErrorMessage = errorMsg
+ if idemKey.Valid {
+ j.IdempotencyKey = idemKey.String
+ }
+ return &j, nil
+}
+
+func (d *DB) ListJobs(filter models.JobFilter) ([]models.Job, error) {
+ if filter.Limit <= 0 {
+ filter.Limit = 50
+ }
+
+ var conditions []string
+ var args []interface{}
+
+ if filter.Type != "" {
+ conditions = append(conditions, "type = ?")
+ args = append(args, filter.Type)
+ }
+ if filter.Status != "" {
+ conditions = append(conditions, "status = ?")
+ args = append(args, filter.Status)
+ }
+
+ whereClause := ""
+ if len(conditions) > 0 {
+ whereClause = "WHERE " + strings.Join(conditions, " AND ")
+ }
+
+ query := fmt.Sprintf(`
+ SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
+ FROM jobs
+ %s
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ `, whereClause)
+
+ args = append(args, filter.Limit, filter.Offset)
+ rows, err := d.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _ = rows.Close()
+ }()
+
+ var jobs []models.Job
+ for rows.Next() {
+ var j models.Job
+ var payloadStr, resultStr, errorMsg string
+ var idemKey sql.NullString
+
+ err := rows.Scan(
+ &j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
+ &errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal([]byte(payloadStr), &j.Payload)
+ _ = json.Unmarshal([]byte(resultStr), &j.Result)
+ j.ErrorMessage = errorMsg
+ if idemKey.Valid {
+ j.IdempotencyKey = idemKey.String
+ }
+ jobs = append(jobs, j)
+ }
+ return jobs, nil
+}
+
+func (d *DB) UpdateJob(job models.Job) error {
+ job.UpdatedAt = time.Now().UTC()
+ payloadBytes, _ := json.Marshal(job.Payload)
+ resultBytes, _ := json.Marshal(job.Result)
+ if len(resultBytes) == 0 {
+ resultBytes = []byte("{}")
+ }
+
+ query := `
+ UPDATE jobs SET
+ status = ?, progress = ?, result = ?, error_message = ?,
+ attempts = ?, run_at = ?, updated_at = ?
+ WHERE id = ?
+ `
+ _, err := d.Exec(
+ query,
+ job.Status, job.Progress, string(resultBytes), job.ErrorMessage,
+ job.Attempts, job.RunAt, job.UpdatedAt, job.ID,
+ )
+ _ = payloadBytes
+ return err
+}
+
+func (d *DB) ClaimNextJob(types []string) (*models.Job, error) {
+ now := time.Now().UTC()
+ var typeFilter string
+ var args []interface{}
+ args = append(args, now)
+
+ if len(types) > 0 {
+ placeholders := make([]string, len(types))
+ for i, t := range types {
+ placeholders[i] = "?"
+ args = append(args, t)
+ }
+ typeFilter = fmt.Sprintf("AND type IN (%s)", strings.Join(placeholders, ","))
+ }
+
+ query := fmt.Sprintf(`
+ SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
+ FROM jobs
+ WHERE status = 'pending' AND run_at <= ? %s
+ ORDER BY run_at ASC, created_at ASC
+ LIMIT 1
+ `, typeFilter)
+
+ var j models.Job
+ var payloadStr, resultStr, errorMsg string
+ var idemKey sql.NullString
+
+ err := d.QueryRow(query, args...).Scan(
+ &j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
+ &errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Atomically mark as running
+ updateRes, err := d.Exec("UPDATE jobs SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'", now, j.ID)
+ if err != nil {
+ return nil, err
+ }
+ rowsAffected, _ := updateRes.RowsAffected()
+ if rowsAffected == 0 {
+ return nil, sql.ErrNoRows
+ }
+
+ _ = json.Unmarshal([]byte(payloadStr), &j.Payload)
+ _ = json.Unmarshal([]byte(resultStr), &j.Result)
+ j.ErrorMessage = errorMsg
+ j.Status = "running"
+ if idemKey.Valid {
+ j.IdempotencyKey = idemKey.String
+ }
+ return &j, nil
+}
+
+func (d *DB) ResetRunningJobsToPending() error {
+ now := time.Now().UTC()
+ _, err := d.Exec("UPDATE jobs SET status = 'pending', updated_at = ? WHERE status = 'running'", now)
+ return err
+}
+
+func (d *DB) CancelJob(id string) error {
+ now := time.Now().UTC()
+ _, err := d.Exec("UPDATE jobs SET status = 'cancelled', updated_at = ? WHERE id = ? AND status IN ('pending', 'running')", now, id)
+ return err
+}
+
+// GetSharedEntry strictly retrieves an entry by share_uuid only (prevents data leak of unshared articles)
+func (d *DB) GetSharedEntry(shareUUID string) (*models.FeedEntry, error) {
+ if shareUUID == "" {
+ return nil, sql.ErrNoRows
+ }
+ var e models.FeedEntry
+ var fTitle, fSiteURL, author, rawContent, metaStr, sUUID sql.NullString
+ query := `
+ SELECT
+ e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
+ e.guid, e.url, e.title, e.author, e.content, e.raw_content,
+ e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
+ FROM feed_entries e
+ JOIN feeds f ON f.id = e.feed_id
+ WHERE e.share_uuid IS NOT NULL AND e.share_uuid != '' AND e.share_uuid = ?
+ `
+ err := d.QueryRow(query, shareUUID).Scan(
+ &e.ID, &e.FeedID, &fTitle, &fSiteURL,
+ &e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
+ &e.PublishedAt, &e.IsRead, &e.IsStarred, &sUUID, &metaStr, &e.CreatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ if fTitle.Valid {
+ e.FeedTitle = fTitle.String
+ }
+ if fSiteURL.Valid {
+ e.FeedSiteURL = fSiteURL.String
+ }
+ if author.Valid {
+ e.Author = author.String
+ }
+ if sUUID.Valid {
+ e.ShareUUID = &sUUID.String
+ }
+
+ variants, _ := d.ListVariantsForEntry(e.ID)
+ if len(variants) > 0 {
+ e.Variants = make(map[string]models.EntryVariant)
+ for _, v := range variants {
+ e.Variants[v.VariantKey] = v
+ }
+ }
+ return &e, nil
+}
+
diff --git a/internal/events/bus.go b/internal/events/bus.go
index d0cf321..0946f31 100644
--- a/internal/events/bus.go
+++ b/internal/events/bus.go
@@ -12,15 +12,24 @@ import (
type EventType string
const (
- EventPostCreated EventType = "on_post_created"
- EventPostUpdated EventType = "on_post_updated"
- EventVariantUpdated EventType = "on_variant_updated"
- EventManualTrigger EventType = "manual"
+ EventPostCreated EventType = "on_post_created"
+ EventPostUpdated EventType = "on_post_updated"
+ EventVariantUpdated EventType = "on_variant_updated"
+ EventEntryTransition EventType = "on_entry_transition"
+ EventManualTrigger EventType = "manual"
+ EventJobProgress EventType = "job_progress"
+ EventJobCompleted EventType = "job_completed"
+ EventJobFailed EventType = "job_failed"
+ EventPreferencesSync EventType = "preferences_updated"
)
type Event struct {
Type EventType
Entry models.FeedEntry
+ OldEntry *models.FeedEntry
+ NewEntry *models.FeedEntry
+ Diff *models.EntryDiff
+ Job *models.Job
VariantKey string // populated when Type == EventVariantUpdated
Depth int
DirectAttrs map[string]interface{}
diff --git a/internal/models/models.go b/internal/models/models.go
index 3b1f02b..f8a3288 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -67,7 +67,7 @@ type FeedEntry struct {
PublishedAt time.Time `json:"published_at"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
- ShareUUID *string `json:"share_uuid,omitempty"`
+ ShareUUID *string `json:"share_uuid"`
Variants map[string]EntryVariant `json:"variants,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -175,3 +175,96 @@ type ChangePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
+
+// Job represents a background task in the persistent job queue
+type Job struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Payload map[string]interface{} `json:"payload"`
+ Status string `json:"status"` // 'pending', 'running', 'completed', 'failed', 'cancelled'
+ Progress int `json:"progress"`
+ Result map[string]interface{} `json:"result,omitempty"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ Attempts int `json:"attempts"`
+ MaxAttempts int `json:"max_attempts"`
+ IdempotencyKey string `json:"idempotency_key,omitempty"`
+ RunAt time.Time `json:"run_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// JobFilter is used to filter job listings
+type JobFilter struct {
+ Type string
+ Status string
+ Limit int
+ Offset int
+}
+
+// PatchEntryRequest is the body for PATCH /api/entries/{id}
+type PatchEntryRequest struct {
+ IsRead *bool `json:"is_read,omitempty"`
+ IsStarred *bool `json:"is_starred,omitempty"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
+ Variants map[string]*EntryVariant `json:"variants,omitempty"`
+}
+
+// EntryDiff represents the delta between OldState and NewState of an Entry
+type EntryDiff struct {
+ FieldsChanged []string `json:"fields_changed"` // e.g. ["is_read", "is_starred", "metadata.tags"]
+ VariantsAdded []string `json:"variants_added"` // e.g. ["readability_full_text"]
+ VariantsChanged []string `json:"variants_changed"` // e.g. ["ai_summary"]
+ VariantsRemoved []string `json:"variants_removed"`
+}
+
+// HasVariantAddition checks if a specific variant key was newly added in this transition
+func (d EntryDiff) HasVariantAddition(variantKey string) bool {
+ for _, k := range d.VariantsAdded {
+ if k == variantKey {
+ return true
+ }
+ }
+ return false
+}
+
+// HasVariantChange checks if a specific variant key was modified in this transition
+func (d EntryDiff) HasVariantChange(variantKey string) bool {
+ for _, k := range d.VariantsChanged {
+ if k == variantKey {
+ return true
+ }
+ }
+ return false
+}
+
+// HasFieldChange checks if a specific top-level or metadata field changed
+func (d EntryDiff) HasFieldChange(fieldName string) bool {
+ for _, f := range d.FieldsChanged {
+ if f == fieldName {
+ return true
+ }
+ }
+ return false
+}
+
+// PromptTemplate represents an AI prompt template
+type PromptTemplate struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Prompt string `json:"prompt"`
+ IsDefault bool `json:"is_default,omitempty"`
+}
+
+// AISettings represents AI provider configuration
+type AISettings struct {
+ Enabled bool `json:"enabled"`
+ Provider string `json:"provider"`
+ BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key"`
+ Model string `json:"model"`
+ Temperature float64 `json:"temperature"`
+ MaxTokens int `json:"max_tokens"`
+ Prompts []PromptTemplate `json:"prompts"`
+}
+
diff --git a/internal/rules/engine.go b/internal/rules/engine.go
index cb973e4..15a143c 100644
--- a/internal/rules/engine.go
+++ b/internal/rules/engine.go
@@ -2,6 +2,9 @@ package rules
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
@@ -14,11 +17,16 @@ type WorkerDispatcher interface {
Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int)
}
+type JobEnqueuer interface {
+ Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error)
+}
+
type Engine struct {
db *db.DB
bus *events.EventBus
evaluator *Evaluator
dispatcher WorkerDispatcher
+ enqueuer JobEnqueuer
log *zap.Logger
}
@@ -32,8 +40,13 @@ func NewEngine(database *db.DB, bus *events.EventBus, dispatcher WorkerDispatche
}
}
+func (e *Engine) SetEnqueuer(enqueuer JobEnqueuer) {
+ e.enqueuer = enqueuer
+}
+
// Start registers event bus subscriptions to evaluate rules reactively.
func (e *Engine) Start(ctx context.Context) {
+ e.bus.Subscribe(events.EventEntryTransition, e.handleTransitionEvent)
e.bus.Subscribe(events.EventPostCreated, e.handleEvent)
e.bus.Subscribe(events.EventPostUpdated, e.handleEvent)
e.bus.Subscribe(events.EventVariantUpdated, e.handleEvent)
@@ -41,6 +54,44 @@ func (e *Engine) Start(ctx context.Context) {
e.log.Info("Rules engine started, listening for events")
}
+func (e *Engine) handleTransitionEvent(ctx context.Context, evt events.Event) error {
+ rules, err := e.db.ListRules()
+ if err != nil {
+ e.log.Error("Failed to fetch rules for transition", zap.Error(err))
+ return err
+ }
+
+ newEntry := evt.NewEntry
+ if newEntry == nil {
+ newEntry = &evt.Entry
+ }
+
+ for _, r := range rules {
+ if !r.IsActive {
+ continue
+ }
+
+ // Check trigger type compatibility
+ trigger := r.Trigger
+ if trigger != "on_update" && trigger != "on_entry_transition" && trigger != "on_variant_updated" && trigger != "on_post_updated" && trigger != "on_post_created" && trigger != "variant_change" {
+ continue
+ }
+
+ if e.evaluator.EvaluateTransition(r, evt.OldEntry, newEntry, evt.Diff) {
+ e.log.Info("Transition rule matched",
+ zap.String("rule_id", r.ID),
+ zap.String("rule_name", r.Name),
+ zap.String("entry_id", newEntry.ID),
+ zap.Int("actions_count", len(r.Actions)),
+ )
+
+ e.executeRuleActions(ctx, r, *newEntry, evt.Depth)
+ }
+ }
+
+ return nil
+}
+
func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
trigger := string(evt.Type)
rules, err := e.db.GetActiveRules(trigger)
@@ -53,7 +104,6 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
return nil
}
- // Fetch full entry with latest variants if entry ID is present
entry := evt.Entry
if entry.ID != "" {
if full, err := e.db.GetEntry(entry.ID); err == nil && full != nil {
@@ -62,7 +112,7 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
}
for _, r := range rules {
- if e.evaluator.Evaluate(r, entry) {
+ if e.evaluator.EvaluateTransition(r, evt.OldEntry, &entry, evt.Diff) {
e.log.Info("Rule matched",
zap.String("rule_id", r.ID),
zap.String("rule_name", r.Name),
@@ -70,13 +120,43 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
zap.Int("actions_count", len(r.Actions)),
)
- for _, action := range r.Actions {
- if e.dispatcher != nil {
- e.dispatcher.Dispatch(ctx, action, entry, evt.Depth+1)
- }
- }
+ e.executeRuleActions(ctx, r, entry, evt.Depth)
}
}
return nil
}
+
+func (e *Engine) executeRuleActions(ctx context.Context, r models.Rule, entry models.FeedEntry, depth int) {
+ for _, action := range r.Actions {
+ // Prefer enqueuing async jobs to the persistent task queue if available
+ if e.enqueuer != nil {
+ payload := make(map[string]interface{})
+ if action.Params != nil {
+ for k, v := range action.Params {
+ payload[k] = v
+ }
+ }
+ payload["entry_id"] = entry.ID
+ payload["feed_id"] = entry.FeedID
+ payload["rule_id"] = r.ID
+ payload["rule_name"] = r.Name
+
+ // Generate deterministic idempotency key
+ rawKey := fmt.Sprintf("%s:%s:%s:%d:%d", r.ID, entry.ID, action.Type, entry.CreatedAt.Unix(), entry.PublishedAt.Unix())
+ h := sha256.Sum256([]byte(rawKey))
+ idempotencyKey := hex.EncodeToString(h[:16])
+
+ _, err := e.enqueuer.Enqueue(ctx, action.Type, payload, idempotencyKey)
+ if err != nil {
+ e.log.Error("Failed to enqueue job for rule action",
+ zap.String("rule_id", r.ID),
+ zap.String("action", action.Type),
+ zap.Error(err),
+ )
+ }
+ } else if e.dispatcher != nil {
+ e.dispatcher.Dispatch(ctx, action, entry, depth+1)
+ }
+ }
+}
diff --git a/internal/rules/evaluator.go b/internal/rules/evaluator.go
index 867f19f..12b9459 100644
--- a/internal/rules/evaluator.go
+++ b/internal/rules/evaluator.go
@@ -2,6 +2,7 @@ package rules
import (
"fmt"
+ "reflect"
"regexp"
"strconv"
"strings"
@@ -16,8 +17,16 @@ func NewEvaluator() *Evaluator {
return &Evaluator{}
}
-// Evaluate checks whether a FeedEntry satisfies a Rule's conditions.
+// Evaluate checks whether a single FeedEntry satisfies a Rule's conditions (legacy / static evaluation).
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
+ return e.EvaluateTransition(rule, nil, &entry, nil)
+}
+
+// EvaluateTransition evaluates a rule against the (OldState, NewState, EntryDiff) transition delta.
+func (e *Evaluator) EvaluateTransition(rule models.Rule, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
+ if newEntry == nil {
+ return false
+ }
if len(rule.Conditions) == 0 {
return true
}
@@ -25,7 +34,7 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
matchMode := strings.ToLower(rule.MatchMode)
if matchMode == "" || matchMode == "all" {
for _, cond := range rule.Conditions {
- if !e.evaluateCondition(cond, entry) {
+ if !e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
return false
}
}
@@ -34,20 +43,109 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
// matchMode == "any"
for _, cond := range rule.Conditions {
- if e.evaluateCondition(cond, entry) {
+ if e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
return true
}
}
return false
}
-func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool {
- actualValue := e.resolveField(cond.Field, entry)
- return e.compare(actualValue, cond.Operator, cond.Value)
+func (e *Evaluator) evaluateTransitionCondition(cond models.RuleCondition, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
+ opLower := strings.ToLower(strings.TrimSpace(cond.Operator))
+
+ // 1. Transition predicates: became_present, changed, removed
+ switch opLower {
+ case "became_present", "added":
+ return e.checkBecamePresent(cond.Field, oldEntry, newEntry, diff)
+ case "changed", "updated":
+ return e.checkFieldChanged(cond.Field, oldEntry, newEntry, diff)
+ case "removed", "deleted":
+ return e.checkFieldRemoved(cond.Field, oldEntry, newEntry, diff)
+ }
+
+ // 2. Standard state guards & value comparisons evaluated on NewState
+ actualValue := e.resolveField(cond.Field, newEntry)
+ return e.compare(actualValue, opLower, cond.Value)
}
-func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface{} {
- switch strings.ToLower(field) {
+func (e *Evaluator) checkBecamePresent(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
+ if newEntry == nil {
+ return false
+ }
+
+ // If diff is available, check direct addition
+ if strings.HasPrefix(field, "variants.") {
+ vKey := strings.TrimPrefix(field, "variants.")
+ if strings.Contains(vKey, ".") {
+ vKey = strings.Split(vKey, ".")[0]
+ }
+ if diff != nil && diff.HasVariantAddition(vKey) {
+ return true
+ }
+ // Fallback comparison between oldEntry and newEntry
+ oldHas := oldEntry != nil && oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
+ newHas := newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
+ return !oldHas && newHas
+ }
+
+ if field == "metadata.tags" || field == "tags" {
+ oldTags := getTagsSlice(oldEntry)
+ newTags := getTagsSlice(newEntry)
+ return len(oldTags) == 0 && len(newTags) > 0
+ }
+
+ oldVal := e.resolveField(field, oldEntry)
+ newVal := e.resolveField(field, newEntry)
+ return (oldVal == nil || fmt.Sprintf("%v", oldVal) == "") && (newVal != nil && fmt.Sprintf("%v", newVal) != "")
+}
+
+func (e *Evaluator) checkFieldChanged(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
+ if oldEntry == nil || newEntry == nil {
+ return false
+ }
+ if strings.HasPrefix(field, "variants.") {
+ vKey := strings.TrimPrefix(field, "variants.")
+ if strings.Contains(vKey, ".") {
+ vKey = strings.Split(vKey, ".")[0]
+ }
+ if diff != nil && (diff.HasVariantAddition(vKey) || diff.HasVariantChange(vKey)) {
+ return true
+ }
+ }
+ oldVal := e.resolveField(field, oldEntry)
+ newVal := e.resolveField(field, newEntry)
+ return !reflect.DeepEqual(oldVal, newVal)
+}
+
+func (e *Evaluator) checkFieldRemoved(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
+ if oldEntry == nil {
+ return false
+ }
+ if strings.HasPrefix(field, "variants.") {
+ vKey := strings.TrimPrefix(field, "variants.")
+ if diff != nil {
+ for _, r := range diff.VariantsRemoved {
+ if r == vKey {
+ return true
+ }
+ }
+ }
+ oldHas := oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
+ newHas := newEntry != nil && newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
+ return oldHas && !newHas
+ }
+ oldVal := e.resolveField(field, oldEntry)
+ newVal := e.resolveField(field, newEntry)
+ return (oldVal != nil && fmt.Sprintf("%v", oldVal) != "") && (newVal == nil || fmt.Sprintf("%v", newVal) == "")
+}
+
+func (e *Evaluator) resolveField(field string, entry *models.FeedEntry) interface{} {
+ if entry == nil {
+ return nil
+ }
+
+ fieldLower := strings.ToLower(strings.TrimSpace(field))
+ switch fieldLower {
case "feed_id":
return entry.FeedID
case "feed_title":
@@ -83,16 +181,34 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
return entry.PublishedAt
case "created_at":
return entry.CreatedAt
+ case "tags", "metadata.tags":
+ return getTagsSlice(entry)
}
- // Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
- if strings.HasPrefix(field, "variants.") {
- variantKey := strings.TrimPrefix(field, "variants.")
+ // Variant resolution: "variants.ai_summary.word_count", "variants.ai_summary", "variants.readability_full_text"
+ if strings.HasPrefix(fieldLower, "variants.") {
+ path := strings.TrimPrefix(fieldLower, "variants.")
+ parts := strings.Split(path, ".")
+ vKey := parts[0]
+
if entry.Variants != nil {
- if v, ok := entry.Variants[variantKey]; ok {
+ if v, ok := entry.Variants[vKey]; ok {
+ if len(parts) > 1 {
+ subProp := parts[1]
+ if subProp == "word_count" {
+ return float64(len(strings.Fields(v.TextValue)))
+ }
+ if subProp == "char_count" || subProp == "length" {
+ return float64(len(v.TextValue))
+ }
+ if v.Metadata != nil {
+ return v.Metadata[subProp]
+ }
+ }
return v.TextValue
}
}
+ return nil
}
return nil
@@ -101,35 +217,85 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
opLower := strings.ToLower(strings.TrimSpace(op))
- // Handle exists / not_exists operators
- if opLower == "exists" {
- if actual == nil {
- return false
- }
- if s, ok := actual.(string); ok && s == "" {
- return false
- }
- return true
- }
- if opLower == "not_exists" {
+ // Handle is_empty / is_present / exists / not_exists
+ if opLower == "is_empty" || opLower == "not_exists" {
if actual == nil {
return true
}
- if s, ok := actual.(string); ok && s == "" {
+ if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
+ return true
+ }
+ if slice, ok := actual.([]string); ok && len(slice) == 0 {
return true
}
return false
}
+ if opLower == "is_present" || opLower == "exists" {
+ if actual == nil {
+ return false
+ }
+ if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
+ return false
+ }
+ if slice, ok := actual.([]string); ok && len(slice) == 0 {
+ return false
+ }
+ return true
+ }
if actual == nil {
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
}
- // Handle Time/Timestamp comparisons
+ // Handle numeric comparisons (word_count >= 500, etc.)
+ if actualNum, ok := toFloat64(actual); ok == nil {
+ if expNum, okExp := toFloat64(expected); okExp == nil {
+ switch opLower {
+ case ">=":
+ return actualNum >= expNum
+ case ">":
+ return actualNum > expNum
+ case "<=":
+ return actualNum <= expNum
+ case "<":
+ return actualNum < expNum
+ case "eq", "==":
+ return actualNum == expNum
+ case "neq", "!=":
+ return actualNum != expNum
+ }
+ }
+ }
+
+ // Handle Time comparisons
if actualTime, ok := actual.(time.Time); ok {
return e.compareTime(actualTime, opLower, expected)
}
+ // Handle slice containment (e.g. tags contains "Tech")
+ if actualSlice, ok := actual.([]string); ok {
+ expectedStr := fmt.Sprintf("%v", expected)
+ switch opLower {
+ case "contains", "in":
+ for _, item := range actualSlice {
+ if strings.EqualFold(item, expectedStr) {
+ return true
+ }
+ }
+ return false
+ case "contains_any":
+ tokens := parseStringList(expected)
+ for _, item := range actualSlice {
+ for _, t := range tokens {
+ if strings.EqualFold(item, t) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+ }
+
actualStr := fmt.Sprintf("%v", actual)
expectedStr := fmt.Sprintf("%v", expected)
@@ -218,6 +384,16 @@ func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bo
return false
}
+func getTagsSlice(entry *models.FeedEntry) []string {
+ if entry == nil || entry.Metadata == nil {
+ return nil
+ }
+ if raw, ok := entry.Metadata["tags"]; ok {
+ return parseStringList(raw)
+ }
+ return nil
+}
+
func parseStringList(val interface{}) []string {
var result []string
clean := func(s string) string {
@@ -279,7 +455,6 @@ func parseTime(val interface{}) (time.Time, error) {
func ValidateRuleSafety(rule models.Rule) error {
trigger := strings.ToLower(rule.Trigger)
- // If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key
if trigger == "on_variant_updated" || trigger == "variant_change" {
for _, action := range rule.Actions {
if action.Type == "set_variant" {
@@ -288,7 +463,6 @@ func ValidateRuleSafety(rule models.Rule) error {
targetKey, _ = action.Params["key"].(string)
}
- // Check if trigger targets this exact key without safe conditions
for _, cond := range rule.Conditions {
if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") {
return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey)
diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go
index 8eaa56b..5d64c7e 100644
--- a/internal/rules/rules_test.go
+++ b/internal/rules/rules_test.go
@@ -116,3 +116,73 @@ func TestRuleEvaluator(t *testing.T) {
t.Error("expected ValidateRuleSafety to detect cyclic loop")
}
}
+
+func TestTransitionEvaluation(t *testing.T) {
+ eval := NewEvaluator()
+
+ oldEntry := models.FeedEntry{
+ ID: "entry-2",
+ FeedID: "feed-1",
+ FeedTitle: "Tech Blog",
+ Title: "Deep Dive into Go Scheduler",
+ Content: "Short intro...",
+ Variants: map[string]models.EntryVariant{},
+ }
+
+ newEntryWithFullText := models.FeedEntry{
+ ID: "entry-2",
+ FeedID: "feed-1",
+ FeedTitle: "Tech Blog",
+ Title: "Deep Dive into Go Scheduler",
+ Content: "Short intro...",
+ Variants: map[string]models.EntryVariant{
+ "readability_full_text": {
+ EntryID: "entry-2",
+ VariantKey: "readability_full_text",
+ TextValue: "Full article content here with extensive details...",
+ },
+ },
+ }
+
+ diff := &models.EntryDiff{
+ VariantsAdded: []string{"readability_full_text"},
+ }
+
+ // Rule: trigger AI summary when readability_full_text became_present AND ai_summary is empty
+ aiRule := models.Rule{
+ ID: "r-ai",
+ Name: "AI summary on full text added",
+ MatchMode: "all",
+ Conditions: []models.RuleCondition{
+ {Field: "variants.readability_full_text", Operator: "became_present"},
+ {Field: "variants.ai_summary", Operator: "is_empty"},
+ },
+ }
+
+ // Should match when full text was added
+ if !eval.EvaluateTransition(aiRule, &oldEntry, &newEntryWithFullText, diff) {
+ t.Error("expected aiRule to match when readability_full_text became_present")
+ }
+
+ // Should NOT match if tag was added instead of full text
+ diffOnlyTags := &models.EntryDiff{
+ FieldsChanged: []string{"metadata.tags"},
+ }
+ if eval.EvaluateTransition(aiRule, &oldEntry, &oldEntry, diffOnlyTags) {
+ t.Error("expected aiRule to NOT match when only tags changed")
+ }
+
+ // Test word count condition for TTS: word_count >= 5
+ ttsRule := models.Rule{
+ ID: "r-tts",
+ Name: "TTS on long summary",
+ MatchMode: "all",
+ Conditions: []models.RuleCondition{
+ {Field: "variants.readability_full_text.word_count", Operator: ">=", Value: 5},
+ },
+ }
+
+ if !eval.EvaluateTransition(ttsRule, &oldEntry, &newEntryWithFullText, diff) {
+ t.Error("expected ttsRule to match when word_count >= 5")
+ }
+}
diff --git a/internal/services/entry_service.go b/internal/services/entry_service.go
new file mode 100644
index 0000000..373283c
--- /dev/null
+++ b/internal/services/entry_service.go
@@ -0,0 +1,218 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "strings"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "go.uber.org/zap"
+)
+
+type EntryService struct {
+ db *db.DB
+ bus *events.EventBus
+ log *zap.Logger
+}
+
+func NewEntryService(database *db.DB, bus *events.EventBus) *EntryService {
+ return &EntryService{
+ db: database,
+ bus: bus,
+ log: logger.Named("entry-service"),
+ }
+}
+
+// IngestEntries handles bulk ingestion of parsed feed entries (from crawler or OPML import)
+func (s *EntryService) IngestEntries(ctx context.Context, feedID string, entries []models.FeedEntry, feedTitle, feedSiteURL string) ([]models.FeedEntry, error) {
+ newEntries, err := s.db.UpsertFeedEntries(feedID, entries)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, entry := range newEntries {
+ entry.FeedTitle = feedTitle
+ entry.FeedSiteURL = feedSiteURL
+
+ diff := &models.EntryDiff{
+ FieldsChanged: []string{"id", "title", "content", "url"},
+ }
+
+ if s.bus != nil {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventPostCreated,
+ Entry: entry,
+ NewEntry: &entry,
+ Diff: diff,
+ Depth: 0,
+ })
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventEntryTransition,
+ Entry: entry,
+ NewEntry: &entry,
+ Diff: diff,
+ Depth: 0,
+ })
+ }
+ }
+
+ return newEntries, nil
+}
+
+// PatchEntry applies atomic mutations to an entry, calculates the EntryDiff between Old and New states,
+// and fires EventEntryTransition to trigger downstream transition-based automation rules.
+func (s *EntryService) PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error) {
+ oldEntry, err := s.db.GetEntry(entryID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("entry not found: %w", err)
+ }
+
+ diff := &models.EntryDiff{
+ FieldsChanged: make([]string, 0),
+ VariantsAdded: make([]string, 0),
+ VariantsChanged: make([]string, 0),
+ VariantsRemoved: make([]string, 0),
+ }
+
+ // 1. Update IsRead
+ if patch.IsRead != nil && *patch.IsRead != oldEntry.IsRead {
+ if err := s.db.UpdateEntryRead(entryID, *patch.IsRead); err != nil {
+ return nil, nil, err
+ }
+ diff.FieldsChanged = append(diff.FieldsChanged, "is_read")
+ }
+
+ // 2. Update IsStarred
+ if patch.IsStarred != nil && *patch.IsStarred != oldEntry.IsStarred {
+ if err := s.db.UpdateEntryStarred(entryID, *patch.IsStarred); err != nil {
+ return nil, nil, err
+ }
+ diff.FieldsChanged = append(diff.FieldsChanged, "is_starred")
+ }
+
+ // 3. Update Metadata (e.g. tags)
+ if patch.Metadata != nil {
+ if oldEntry.Metadata == nil {
+ oldEntry.Metadata = make(map[string]interface{})
+ }
+ metadataChanged := false
+ for k, v := range patch.Metadata {
+ if !reflect.DeepEqual(oldEntry.Metadata[k], v) {
+ oldEntry.Metadata[k] = v
+ metadataChanged = true
+ diff.FieldsChanged = append(diff.FieldsChanged, "metadata."+k)
+ }
+ }
+ if metadataChanged {
+ if err := s.db.UpdateEntryMetadata(entryID, oldEntry.Metadata); err != nil {
+ return nil, nil, err
+ }
+ }
+ }
+
+ // 4. Update Variants
+ if patch.Variants != nil {
+ for vKey, vVal := range patch.Variants {
+ if vVal == nil {
+ // Variant removal
+ if oldEntry.Variants != nil {
+ if _, exists := oldEntry.Variants[vKey]; exists {
+ _ = s.db.DeleteVariant(entryID, vKey)
+ diff.VariantsRemoved = append(diff.VariantsRemoved, vKey)
+ }
+ }
+ continue
+ }
+
+ // Variant add or update
+ vVal.EntryID = entryID
+ vVal.VariantKey = vKey
+
+ oldVariant, exists := oldEntry.Variants[vKey]
+ if !exists {
+ diff.VariantsAdded = append(diff.VariantsAdded, vKey)
+ } else if oldVariant.TextValue != vVal.TextValue || oldVariant.BlobPath != vVal.BlobPath || !reflect.DeepEqual(oldVariant.Metadata, vVal.Metadata) {
+ diff.VariantsChanged = append(diff.VariantsChanged, vKey)
+ }
+
+ if err := s.db.SetVariant(*vVal); err != nil {
+ return nil, nil, fmt.Errorf("failed to save variant %s: %w", vKey, err)
+ }
+ }
+ }
+
+ // Fetch fresh NewState
+ newEntry, err := s.db.GetEntry(entryID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to retrieve updated entry: %w", err)
+ }
+
+ s.log.Debug("Entry patched with transition diff",
+ zap.String("entry_id", entryID),
+ zap.Strings("fields_changed", diff.FieldsChanged),
+ zap.Strings("variants_added", diff.VariantsAdded),
+ zap.Strings("variants_changed", diff.VariantsChanged),
+ )
+
+ // Publish transition event to trigger reactive rules
+ if s.bus != nil {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventEntryTransition,
+ Entry: *newEntry,
+ OldEntry: oldEntry,
+ NewEntry: newEntry,
+ Diff: diff,
+ Depth: 0,
+ })
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventPostUpdated,
+ Entry: *newEntry,
+ OldEntry: oldEntry,
+ NewEntry: newEntry,
+ Diff: diff,
+ Depth: 0,
+ })
+ for _, vKey := range diff.VariantsAdded {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventVariantUpdated,
+ Entry: *newEntry,
+ VariantKey: vKey,
+ Depth: 0,
+ })
+ }
+ for _, vKey := range diff.VariantsChanged {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventVariantUpdated,
+ Entry: *newEntry,
+ VariantKey: vKey,
+ Depth: 0,
+ })
+ }
+ }
+
+ return newEntry, diff, nil
+}
+
+func (s *EntryService) GetEntry(ctx context.Context, id string) (*models.FeedEntry, error) {
+ return s.db.GetEntry(id)
+}
+
+func (s *EntryService) ListEntries(ctx context.Context, filter db.EntryFilter) ([]models.FeedEntry, int, error) {
+ return s.db.ListEntries(filter)
+}
+
+func (s *EntryService) MarkAllRead(ctx context.Context, feedID, categoryID *string) error {
+ return s.db.MarkAllRead(feedID, categoryID)
+}
+
+func (s *EntryService) ToggleShare(ctx context.Context, id string) (*string, error) {
+ return s.db.ToggleShareEntry(id)
+}
+
+func (s *EntryService) GetSharedEntry(ctx context.Context, shareUUID string) (*models.FeedEntry, error) {
+ return s.db.GetSharedEntry(strings.TrimSuffix(shareUUID, ".html"))
+}
diff --git a/internal/services/entry_service_test.go b/internal/services/entry_service_test.go
new file mode 100644
index 0000000..ec01151
--- /dev/null
+++ b/internal/services/entry_service_test.go
@@ -0,0 +1,85 @@
+package services
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+)
+
+func TestEntryServicePatchAndTransition(t *testing.T) {
+ tempDBPath := filepath.Join(t.TempDir(), "test.db")
+ database, err := db.InitDB(tempDBPath)
+ if err != nil {
+ t.Fatalf("failed to init db: %v", err)
+ }
+ defer func() { _ = database.Close() }()
+
+ bus := events.NewEventBus()
+ svc := NewEntryService(database, bus)
+
+ // Create test category and feed
+ cat, _ := database.CreateCategory("Tech", nil)
+ feed, _ := database.CreateFeed("Test Feed", "https://example.com/rss", "https://example.com", "", &cat.ID)
+
+ // Ingest an entry
+ entry := models.FeedEntry{
+ ID: "e_test_1",
+ FeedID: feed.ID,
+ GUID: "guid_1",
+ URL: "https://example.com/p/1",
+ Title: "Test Post",
+ Content: "Hello world",
+ PublishedAt: time.Now().UTC(),
+ }
+
+ ingested, err := svc.IngestEntries(context.Background(), feed.ID, []models.FeedEntry{entry}, feed.Title, feed.SiteURL)
+ if err != nil || len(ingested) == 0 {
+ t.Fatalf("failed to ingest entry: %v", err)
+ }
+
+ // 1. Test patching tags
+ isStarred := true
+ updated, diff, err := svc.PatchEntry(context.Background(), ingested[0].ID, models.PatchEntryRequest{
+ IsStarred: &isStarred,
+ Metadata: map[string]interface{}{
+ "tags": []string{"AI", "Kubernetes"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to patch entry: %v", err)
+ }
+
+ if !updated.IsStarred {
+ t.Errorf("expected entry to be starred")
+ }
+ if !diff.HasFieldChange("is_starred") {
+ t.Errorf("expected diff to contain is_starred change")
+ }
+
+ // 2. Test patching variant addition (readability_full_text)
+ fullTextVariant := &models.EntryVariant{
+ ContentType: "html",
+ TextValue: "Full article extracted content...
",
+ }
+
+ updated2, diff2, err := svc.PatchEntry(context.Background(), ingested[0].ID, models.PatchEntryRequest{
+ Variants: map[string]*models.EntryVariant{
+ "readability_full_text": fullTextVariant,
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to patch variant: %v", err)
+ }
+
+ if !diff2.HasVariantAddition("readability_full_text") {
+ t.Errorf("expected diff2 to report variant addition of readability_full_text")
+ }
+ if updated2.Variants["readability_full_text"].TextValue == "" {
+ t.Errorf("expected variant to be loaded in updated entry")
+ }
+}
diff --git a/internal/services/feed_service.go b/internal/services/feed_service.go
new file mode 100644
index 0000000..dc94cff
--- /dev/null
+++ b/internal/services/feed_service.go
@@ -0,0 +1,186 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
+ "go.uber.org/zap"
+)
+
+type FeedService struct {
+ db *db.DB
+ entryService *EntryService
+ parser *parser.Parser
+ crawler *crawler.Crawler
+ log *zap.Logger
+}
+
+func NewFeedService(database *db.DB, entrySvc *EntryService, cr *crawler.Crawler) *FeedService {
+ return &FeedService{
+ db: database,
+ entryService: entrySvc,
+ parser: parser.NewParser(),
+ crawler: cr,
+ log: logger.Named("feed-service"),
+ }
+}
+
+func (s *FeedService) ListFeeds(ctx context.Context) ([]models.Feed, error) {
+ return s.db.ListFeeds()
+}
+
+func (s *FeedService) GetFeed(ctx context.Context, id string) (*models.Feed, error) {
+ return s.db.GetFeed(id)
+}
+
+func (s *FeedService) CreateFeed(ctx context.Context, req models.CreateFeedRequest) (*models.Feed, error) {
+ feedURL := strings.TrimSpace(req.FeedURL)
+ if feedURL == "" {
+ return nil, fmt.Errorf("feed_url is required")
+ }
+
+ if existing, _ := s.db.GetFeedByURL(feedURL); existing != nil {
+ return nil, fmt.Errorf("feed is already subscribed")
+ }
+
+ res, err := s.parser.FetchFeed(ctx, feedURL, "", "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch feed: %w", err)
+ }
+
+ title := res.Title
+ if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
+ title = strings.TrimSpace(*req.Title)
+ }
+ iconURL := ""
+ if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
+ iconURL = strings.TrimSpace(*req.IconURL)
+ }
+
+ feed, err := s.db.CreateFeed(title, feedURL, res.SiteURL, iconURL, req.CategoryID)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(res.Entries) > 0 {
+ _, _ = s.entryService.IngestEntries(ctx, feed.ID, res.Entries, feed.Title, feed.SiteURL)
+ _ = s.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "")
+ }
+
+ return s.db.GetFeed(feed.ID)
+}
+
+func (s *FeedService) UpdateFeed(ctx context.Context, id string, req models.UpdateFeedRequest) (*models.Feed, error) {
+ if err := s.db.UpdateFeed(id, req); err != nil {
+ return nil, err
+ }
+ return s.db.GetFeed(id)
+}
+
+func (s *FeedService) DeleteFeed(ctx context.Context, id string) error {
+ return s.db.DeleteFeed(id)
+}
+
+func (s *FeedService) RefreshFeed(ctx context.Context, id string) (*models.Feed, error) {
+ if s.crawler != nil {
+ if err := s.crawler.RefreshSingle(ctx, id); err != nil {
+ return nil, err
+ }
+ }
+ return s.db.GetFeed(id)
+}
+
+func (s *FeedService) RefreshAllFeeds(ctx context.Context) {
+ if s.crawler != nil {
+ go s.crawler.RefreshAll(context.Background())
+ }
+}
+
+// Category methods
+func (s *FeedService) ListCategories(ctx context.Context) ([]models.Category, error) {
+ return s.db.ListCategories()
+}
+
+func (s *FeedService) CreateCategory(ctx context.Context, title string, parentID *string) (*models.Category, error) {
+ return s.db.CreateCategory(strings.TrimSpace(title), parentID)
+}
+
+func (s *FeedService) DeleteCategory(ctx context.Context, id string) error {
+ return s.db.DeleteCategory(id)
+}
+
+func (s *FeedService) GetStats(ctx context.Context) (*models.Stats, error) {
+ return s.db.GetStats()
+}
+
+// OPML methods
+func (s *FeedService) ImportOPML(ctx context.Context, data []byte) (int, int, error) {
+ importedFeeds, err := opml.ParseOPML(data)
+ if err != nil {
+ return 0, 0, fmt.Errorf("invalid OPML XML: %w", err)
+ }
+
+ categoryCache := make(map[string]string)
+ cats, _ := s.db.ListCategories()
+ for _, c := range cats {
+ categoryCache[c.Title] = c.ID
+ }
+
+ createdCount := 0
+ for _, item := range importedFeeds {
+ if item.FeedURL == "" {
+ continue
+ }
+ if existing, _ := s.db.GetFeedByURL(item.FeedURL); existing != nil {
+ continue
+ }
+
+ var catID *string
+ if item.CategoryTitle != "" {
+ if cid, ok := categoryCache[item.CategoryTitle]; ok {
+ catID = &cid
+ } else {
+ newCat, err := s.db.CreateCategory(item.CategoryTitle, nil)
+ if err == nil {
+ categoryCache[item.CategoryTitle] = newCat.ID
+ catID = &newCat.ID
+ }
+ }
+ }
+
+ title := item.Title
+ if title == "" {
+ title = item.FeedURL
+ }
+
+ _, err := s.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
+ if err == nil {
+ createdCount++
+ }
+ }
+
+ if s.crawler != nil {
+ go s.crawler.RefreshAll(context.Background())
+ }
+
+ return createdCount, len(importedFeeds), nil
+}
+
+func (s *FeedService) ExportOPML(ctx context.Context) ([]byte, error) {
+ feeds, err := s.db.ListFeeds()
+ if err != nil {
+ return nil, err
+ }
+ cats, err := s.db.ListCategories()
+ if err != nil {
+ return nil, err
+ }
+ return opml.GenerateOPML(feeds, cats)
+}
diff --git a/internal/services/job_service.go b/internal/services/job_service.go
new file mode 100644
index 0000000..862c82e
--- /dev/null
+++ b/internal/services/job_service.go
@@ -0,0 +1,126 @@
+package services
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "time"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+type JobService struct {
+ db *db.DB
+ bus *events.EventBus
+ log *zap.Logger
+}
+
+func NewJobService(database *db.DB, bus *events.EventBus) *JobService {
+ return &JobService{
+ db: database,
+ bus: bus,
+ log: logger.Named("job-service"),
+ }
+}
+
+// GenerateIdempotencyKey creates a deterministic key to prevent duplicate jobs
+func GenerateIdempotencyKey(jobType, entityID, action string, extra string) string {
+ raw := fmt.Sprintf("%s:%s:%s:%s", jobType, entityID, action, extra)
+ h := sha256.Sum256([]byte(raw))
+ return hex.EncodeToString(h[:16])
+}
+
+// Enqueue submits a job to the persistent SQLite queue.
+// If an idempotency key is provided and a job already exists with that key, returns the existing job.
+func (s *JobService) Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error) {
+ if idempotencyKey != "" {
+ if existing, err := s.db.GetJobByIdempotencyKey(idempotencyKey); err == nil && existing != nil {
+ s.log.Debug("Job already exists with idempotency key, skipping duplicate enqueue",
+ zap.String("job_id", existing.ID),
+ zap.String("type", jobType),
+ zap.String("key", idempotencyKey),
+ )
+ return existing, nil
+ }
+ }
+
+ job := models.Job{
+ ID: uuid.New().String(),
+ Type: jobType,
+ Payload: payload,
+ Status: "pending",
+ Progress: 0,
+ Attempts: 0,
+ MaxAttempts: 3,
+ IdempotencyKey: idempotencyKey,
+ RunAt: time.Now().UTC(),
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+
+ created, err := s.db.CreateJob(job)
+ if err != nil {
+ return nil, fmt.Errorf("failed to enqueue job: %w", err)
+ }
+
+ s.log.Info("Job enqueued",
+ zap.String("job_id", created.ID),
+ zap.String("type", created.Type),
+ zap.String("idempotency_key", idempotencyKey),
+ )
+
+ if s.bus != nil {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventJobProgress,
+ Job: created,
+ })
+ }
+
+ return created, nil
+}
+
+func (s *JobService) GetJob(ctx context.Context, id string) (*models.Job, error) {
+ return s.db.GetJob(id)
+}
+
+func (s *JobService) ListJobs(ctx context.Context, filter models.JobFilter) ([]models.Job, error) {
+ return s.db.ListJobs(filter)
+}
+
+func (s *JobService) CancelJob(ctx context.Context, id string) error {
+ err := s.db.CancelJob(id)
+ if err == nil && s.bus != nil {
+ if j, err := s.db.GetJob(id); err == nil {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventJobFailed,
+ Job: j,
+ })
+ }
+ }
+ return err
+}
+
+func (s *JobService) RetryJob(ctx context.Context, id string) error {
+ j, err := s.db.GetJob(id)
+ if err != nil {
+ return err
+ }
+ j.Status = "pending"
+ j.ErrorMessage = ""
+ j.Attempts = 0
+ j.RunAt = time.Now().UTC()
+ err = s.db.UpdateJob(*j)
+ if err == nil && s.bus != nil {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventJobProgress,
+ Job: j,
+ })
+ }
+ return err
+}
diff --git a/internal/services/settings_service.go b/internal/services/settings_service.go
new file mode 100644
index 0000000..e0e178a
--- /dev/null
+++ b/internal/services/settings_service.go
@@ -0,0 +1,98 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "go.uber.org/zap"
+)
+
+type SettingsService struct {
+ db *db.DB
+ bus *events.EventBus
+ log *zap.Logger
+}
+
+func NewSettingsService(database *db.DB, bus *events.EventBus) *SettingsService {
+ return &SettingsService{
+ db: database,
+ bus: bus,
+ log: logger.Named("settings-service"),
+ }
+}
+
+func (s *SettingsService) GetPreferences(ctx context.Context, username string) (map[string]interface{}, error) {
+ user, err := s.db.GetUserByUsername(username)
+ if err != nil {
+ return nil, err
+ }
+ if user.Preferences == nil {
+ return make(map[string]interface{}), nil
+ }
+ return user.Preferences, nil
+}
+
+func (s *SettingsService) UpdatePreferences(ctx context.Context, username string, newPrefs map[string]interface{}) (map[string]interface{}, error) {
+ user, err := s.db.GetUserByUsername(username)
+ if err != nil {
+ return nil, err
+ }
+ if user.Preferences == nil {
+ user.Preferences = make(map[string]interface{})
+ }
+ for k, v := range newPrefs {
+ user.Preferences[k] = v
+ }
+ if err := s.db.UpdateUserPreferences(user.ID, user.Preferences); err != nil {
+ return nil, err
+ }
+
+ if s.bus != nil {
+ s.bus.Publish(ctx, events.Event{
+ Type: events.EventPreferencesSync,
+ DirectAttrs: user.Preferences,
+ })
+ }
+
+ return user.Preferences, nil
+}
+
+func (s *SettingsService) GetAISettings(ctx context.Context) (*models.AISettings, error) {
+ raw, err := s.db.GetSetting("ai_settings")
+ if err != nil || raw == "" {
+ return &models.AISettings{
+ Enabled: false,
+ Provider: "openai",
+ BaseURL: "https://api.openai.com/v1",
+ APIKey: "",
+ Model: "gpt-4o-mini",
+ Temperature: 0.3,
+ MaxTokens: 1000,
+ Prompts: []models.PromptTemplate{},
+ }, nil
+ }
+
+ var cfg models.AISettings
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ return nil, fmt.Errorf("failed to parse AI settings: %w", err)
+ }
+ return &cfg, nil
+}
+
+func (s *SettingsService) UpdateAISettings(ctx context.Context, cfg models.AISettings) (*models.AISettings, error) {
+ bytes, err := json.Marshal(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal AI settings: %w", err)
+ }
+
+ if err := s.db.SetSetting("ai_settings", string(bytes)); err != nil {
+ return nil, err
+ }
+
+ return &cfg, nil
+}
diff --git a/internal/worker/engine.go b/internal/worker/engine.go
new file mode 100644
index 0000000..0ae48bf
--- /dev/null
+++ b/internal/worker/engine.go
@@ -0,0 +1,268 @@
+package worker
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
+ "git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
+ "go.uber.org/zap"
+)
+
+type JobHandlerFunc func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error)
+
+type EntryPatcher interface {
+ PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error)
+ GetEntry(ctx context.Context, id string) (*models.FeedEntry, error)
+}
+
+type JobEngine struct {
+ db *db.DB
+ bus *events.EventBus
+ patcher EntryPatcher
+ handlers map[string]JobHandlerFunc
+ semaphores map[string]chan struct{}
+ maxWorkers int
+ pollTicker *time.Ticker
+ wg sync.WaitGroup
+ log *zap.Logger
+ mu sync.RWMutex
+}
+
+func NewJobEngine(database *db.DB, bus *events.EventBus, patcher EntryPatcher, maxWorkers int) *JobEngine {
+ if maxWorkers <= 0 {
+ maxWorkers = 10
+ }
+
+ engine := &JobEngine{
+ db: database,
+ bus: bus,
+ patcher: patcher,
+ handlers: make(map[string]JobHandlerFunc),
+ semaphores: make(map[string]chan struct{}),
+ maxWorkers: maxWorkers,
+ log: logger.Named("job-engine"),
+ }
+
+ // Set default concurrency limits per task type
+ engine.SetConcurrency("ai_summary", 2)
+ engine.SetConcurrency("ai_enrich", 2)
+ engine.SetConcurrency("readability_scrape", 5)
+ engine.SetConcurrency("tts_generate", 2)
+ engine.SetConcurrency("feed_refresh", 10)
+ engine.SetConcurrency("add_tags", 10)
+
+ return engine
+}
+
+func (e *JobEngine) SetConcurrency(jobType string, limit int) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if limit <= 0 {
+ limit = 2
+ }
+ e.semaphores[jobType] = make(chan struct{}, limit)
+}
+
+func (e *JobEngine) RegisterHandler(jobType string, handler JobHandlerFunc) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.handlers[jobType] = handler
+ if _, ok := e.semaphores[jobType]; !ok {
+ e.semaphores[jobType] = make(chan struct{}, 5)
+ }
+ e.log.Info("Registered job handler", zap.String("type", jobType))
+}
+
+func (e *JobEngine) Start(ctx context.Context) {
+ e.log.Info("Starting persistent SQLite job queue engine")
+
+ // 1. Recover crashed/orphaned jobs on boot
+ if err := e.db.ResetRunningJobsToPending(); err != nil {
+ e.log.Error("Failed to reset orphaned running jobs", zap.Error(err))
+ }
+
+ // 2. Start dispatcher loop
+ e.pollTicker = time.NewTicker(500 * time.Millisecond)
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-e.pollTicker.C:
+ e.dispatchPendingJobs(ctx)
+ }
+ }
+ }()
+}
+
+func (e *JobEngine) Stop() {
+ if e.pollTicker != nil {
+ e.pollTicker.Stop()
+ }
+ e.wg.Wait()
+ e.log.Info("Job engine stopped")
+}
+
+func (e *JobEngine) dispatchPendingJobs(ctx context.Context) {
+ e.mu.RLock()
+ var registeredTypes []string
+ for t := range e.handlers {
+ registeredTypes = append(registeredTypes, t)
+ }
+ e.mu.RUnlock()
+
+ if len(registeredTypes) == 0 {
+ return
+ }
+
+ // Claim next due job
+ job, err := e.db.ClaimNextJob(registeredTypes)
+ if err != nil || job == nil {
+ return
+ }
+
+ // Acquire concurrency slot for this job type
+ e.mu.RLock()
+ sem, hasSem := e.semaphores[job.Type]
+ handler, hasHandler := e.handlers[job.Type]
+ e.mu.RUnlock()
+
+ if !hasHandler {
+ e.log.Warn("No handler registered for claimed job", zap.String("job_id", job.ID), zap.String("type", job.Type))
+ return
+ }
+
+ if hasSem {
+ select {
+ case sem <- struct{}{}:
+ // Slot acquired
+ default:
+ // Concurrency limit reached for this type, release job back to pending
+ job.Status = "pending"
+ _ = e.db.UpdateJob(*job)
+ return
+ }
+ }
+
+ e.wg.Add(1)
+ go func(j models.Job, h JobHandlerFunc, s chan struct{}) {
+ defer e.wg.Done()
+ if s != nil {
+ defer func() { <-s }()
+ }
+
+ e.executeJob(ctx, j, h)
+ }(*job, handler, sem)
+}
+
+func (e *JobEngine) executeJob(ctx context.Context, job models.Job, handler JobHandlerFunc) {
+ e.log.Info("Executing job",
+ zap.String("job_id", job.ID),
+ zap.String("type", job.Type),
+ zap.Int("attempt", job.Attempts+1),
+ )
+
+ // Emit Job Progress
+ if e.bus != nil {
+ e.bus.Publish(ctx, events.Event{
+ Type: events.EventJobProgress,
+ Job: &job,
+ })
+ }
+
+ start := time.Now()
+ patchReq, result, err := handler(ctx, job)
+ duration := time.Since(start)
+
+ if err != nil {
+ job.Attempts++
+ e.log.Error("Job execution failed",
+ zap.String("job_id", job.ID),
+ zap.String("type", job.Type),
+ zap.Int("attempts", job.Attempts),
+ zap.Duration("duration", duration),
+ zap.Error(err),
+ )
+
+ if job.Attempts < job.MaxAttempts {
+ // Exponential backoff retry: 5s, 10s, 20s...
+ backoff := time.Duration(1<
-
+
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 68fdbfb..1aa022a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -306,9 +306,57 @@ export const App: React.FC = () => {
}
};
+ // Connect Real-Time SSE Stream for live state sync across tabs and async background jobs
+ useEffect(() => {
+ if (!currentUser) return;
+
+ const disconnect = api.connectEventStream((eventType, data) => {
+ if (eventType === 'job_completed' || eventType === 'post_updated' || eventType === 'variant_updated') {
+ const updatedEntry = data?.entry || data;
+ if (updatedEntry && updatedEntry.id) {
+ setEntries((prev) =>
+ prev.map((e) =>
+ e.id === updatedEntry.id
+ ? {
+ ...e,
+ ...updatedEntry,
+ variants: { ...(e.variants || {}), ...(updatedEntry.variants || {}) },
+ }
+ : e
+ )
+ );
+ }
+ } else if (eventType === 'post_created') {
+ loadSidebarData();
+ } else if (eventType === 'preferences_updated') {
+ if (data && typeof data === 'object') {
+ if (data.auto_read !== undefined) {
+ localStorage.setItem('rsspipe_auto_read_scroll', data.auto_read ? 'true' : 'false');
+ }
+ if (data.view_preference) {
+ setViewPreference(data.view_preference);
+ localStorage.setItem('rsspipe_view_preference', data.view_preference);
+ }
+ }
+ }
+ });
+
+ return () => disconnect();
+ }, [currentUser, loadSidebarData]);
+
// Update Single Entry Action
const handleUpdateEntry = (updated: FeedEntry) => {
- setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
+ setEntries((prev) =>
+ prev.map((e) =>
+ e.id === updated.id
+ ? {
+ ...e,
+ ...updated,
+ variants: { ...(e.variants || {}), ...(updated.variants || {}) },
+ }
+ : e
+ )
+ );
loadSidebarData();
};
@@ -500,6 +548,7 @@ export const App: React.FC = () => {
onMarkAllRead={handleMarkAllRead}
onUpdateEntry={handleUpdateEntry}
stats={stats}
+ totalEntries={totalEntries}
onOpenSettings={() => setViewMode('settings')}
onOpenAddFeed={() => setShowAddFeedModal(true)}
onPlayAudio={handlePlayAudio}
diff --git a/web/src/components/desktop/DesktopArticleList.tsx b/web/src/components/desktop/DesktopArticleList.tsx
index a63944a..add2550 100644
--- a/web/src/components/desktop/DesktopArticleList.tsx
+++ b/web/src/components/desktop/DesktopArticleList.tsx
@@ -23,6 +23,8 @@ interface DesktopArticleListProps {
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
feeds?: Feed[];
categories?: Category[];
+ stats?: import('../../types').Stats | null;
+ totalEntries?: number;
onFilterChange?: (newFilter: Partial) => void;
}
@@ -52,6 +54,8 @@ export const DesktopArticleList: React.FC = ({
onToggleStar,
feeds = [],
categories = [],
+ stats,
+ totalEntries = 0,
onFilterChange,
}) => {
const getFilterTitle = () => {
@@ -70,7 +74,35 @@ export const DesktopArticleList: React.FC = ({
return 'Inbox';
};
- const unreadCount = entries.filter((e) => !e.is_read).length;
+ // Calculate accurate unread and total counts from feeds/categories/stats
+ const getFilterCounts = () => {
+ if (filter.feedId) {
+ const activeFeed = feeds.find((f) => f.id === filter.feedId);
+ return {
+ unread: activeFeed?.unread_count || 0,
+ total: activeFeed?.total_count || totalEntries || 0,
+ };
+ }
+ if (filter.categoryId) {
+ const catFeeds = feeds.filter((f) => f.category_id === filter.categoryId);
+ const unread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
+ const total = catFeeds.reduce((acc, f) => acc + (f.total_count || 0), 0);
+ return { unread, total: total || totalEntries || 0 };
+ }
+ if (filter.view === 'starred') {
+ return { unread: 0, total: stats?.total_starred || totalEntries || 0 };
+ }
+ if (filter.view === 'all') {
+ return { unread: stats?.total_unread || 0, total: totalEntries || 0 };
+ }
+ if (filter.view === 'shared') {
+ return { unread: 0, total: stats?.total_shared || totalEntries || 0 };
+ }
+ // 'inbox'
+ return { unread: stats?.total_unread || 0, total: stats?.total_unread || totalEntries || 0 };
+ };
+
+ const { unread: trueUnreadCount, total: trueTotalCount } = getFilterCounts();
return (
@@ -80,7 +112,21 @@ export const DesktopArticleList: React.FC = ({
{getFilterTitle()}
- {unreadCount > 0 && {unreadCount}}
+ {filter.view === 'inbox' ? (
+ trueUnreadCount > 0 && {trueUnreadCount}
+ ) : filter.view === 'all' ? (
+ trueTotalCount > 0 && (
+
+ {trueTotalCount}
+
+ )
+ ) : filter.view === 'starred' ? (
+ trueTotalCount > 0 && (
+
+ {trueTotalCount}
+
+ )
+ ) : null}
@@ -104,7 +150,7 @@ export const DesktopArticleList: React.FC = ({
)}
- {unreadCount > 0 && (
+ {trueUnreadCount > 0 && (