From ebcaa5d72a5884d9df9c47cb294e10078b56d79f Mon Sep 17 00:00:00 2001 From: haopengzhan Date: Tue, 1 Sep 2026 00:41:05 -0700 Subject: [PATCH] refactor(core): overhaul state transition architecture, persistent job queue, and API layer - Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md. --- cmd/server/main.go | 41 +- docs/009_worklog.md | 156 +++ internal/actions/ai/enrich.go | 52 +- internal/actions/ai/triage.go | 6 +- internal/actions/register.go | 192 ++- internal/api/auth_handler.go | 146 ++ internal/api/entries_handler.go | 360 +++++ internal/api/feeds_handler.go | 176 +++ internal/api/handlers.go | 1184 ++--------------- internal/api/jobs_handler.go | 70 + internal/api/rules_handler.go | 131 ++ internal/api/server.go | 19 +- internal/api/settings_handler.go | 159 +++ internal/api/sse_handler.go | 104 ++ internal/crawler/crawler.go | 20 +- internal/db/db.go | 351 ++++- internal/events/bus.go | 17 +- internal/models/models.go | 95 +- internal/rules/engine.go | 94 +- internal/rules/evaluator.go | 228 +++- internal/rules/rules_test.go | 70 + internal/services/entry_service.go | 218 +++ internal/services/entry_service_test.go | 85 ++ internal/services/feed_service.go | 186 +++ internal/services/job_service.go | 126 ++ internal/services/settings_service.go | 98 ++ internal/worker/engine.go | 268 ++++ internal/worker/engine_test.go | 84 ++ web/index.html | 2 +- web/src/App.tsx | 51 +- .../components/desktop/DesktopArticleList.tsx | 52 +- web/src/components/desktop/DesktopReader.tsx | 6 +- web/src/components/desktop/ThreePaneView.tsx | 4 + .../components/mobile/MobileArticleSheet.tsx | 6 +- web/src/services/api.ts | 74 ++ web/src/styles/tokens.css | 5 +- web/src/styles/waterfall.css | 12 +- web/src/types.ts | 2 +- 38 files changed, 3748 insertions(+), 1202 deletions(-) create mode 100644 docs/009_worklog.md create mode 100644 internal/api/auth_handler.go create mode 100644 internal/api/entries_handler.go create mode 100644 internal/api/feeds_handler.go create mode 100644 internal/api/jobs_handler.go create mode 100644 internal/api/rules_handler.go create mode 100644 internal/api/settings_handler.go create mode 100644 internal/api/sse_handler.go create mode 100644 internal/services/entry_service.go create mode 100644 internal/services/entry_service_test.go create mode 100644 internal/services/feed_service.go create mode 100644 internal/services/job_service.go create mode 100644 internal/services/settings_service.go create mode 100644 internal/worker/engine.go create mode 100644 internal/worker/engine_test.go 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 ↗

+
+ %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 ↗

-
- %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 && (