package db import ( "database/sql" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" "github.com/google/uuid" _ "modernc.org/sqlite" ) type DB struct { *sql.DB } func InitDB(dbPath string) (*DB, error) { dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create db directory: %w", err) } db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("failed to open sqlite db: %w", err) } db.SetMaxOpenConns(10) 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) } return database, nil } func (d *DB) migrate() error { schema := ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, title TEXT NOT NULL, parent_id TEXT, sort_order INTEGER DEFAULT 0, created_at DATETIME NOT NULL, FOREIGN KEY(parent_id) REFERENCES categories(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS feeds ( id TEXT PRIMARY KEY, title TEXT NOT NULL, feed_url TEXT UNIQUE NOT NULL, site_url TEXT, category_id TEXT, is_virtual_digest BOOLEAN DEFAULT 0, refresh_interval_minutes INTEGER DEFAULT 30, timeout_seconds INTEGER DEFAULT 10, etag TEXT DEFAULT '', last_modified TEXT DEFAULT '', last_fetched_at DATETIME, fetch_error TEXT DEFAULT '', created_at DATETIME NOT NULL, FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS feed_entries ( id TEXT PRIMARY KEY, feed_id TEXT NOT NULL, guid TEXT NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, author TEXT DEFAULT '', content TEXT NOT NULL, raw_content TEXT DEFAULT '', published_at DATETIME NOT NULL, is_read BOOLEAN DEFAULT 0, is_starred BOOLEAN DEFAULT 0, share_uuid TEXT UNIQUE, metadata TEXT DEFAULT '{}', created_at DATETIME NOT NULL, FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE, UNIQUE(feed_id, guid) ); CREATE INDEX IF NOT EXISTS idx_entries_feed_read ON feed_entries(feed_id, is_read); CREATE INDEX IF NOT EXISTS idx_entries_published ON feed_entries(published_at DESC); CREATE INDEX IF NOT EXISTS idx_entries_is_read ON feed_entries(is_read); CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON feed_entries(is_starred); CREATE INDEX IF NOT EXISTS idx_entries_share_uuid ON feed_entries(share_uuid); CREATE TABLE IF NOT EXISTS entry_variants ( entry_id TEXT NOT NULL, variant_key TEXT NOT NULL, content_type TEXT NOT NULL, text_value TEXT, blob_path TEXT, metadata TEXT DEFAULT '{}', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (entry_id, variant_key), FOREIGN KEY(entry_id) REFERENCES feed_entries(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_entry_variants_key ON entry_variants(variant_key); CREATE TABLE IF NOT EXISTS rules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, trigger TEXT NOT NULL, conditions TEXT DEFAULT '[]', match_mode TEXT DEFAULT 'all', actions TEXT DEFAULT '[]', is_active BOOLEAN DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS system_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME NOT NULL ); ` if _, err := d.Exec(schema); err != nil { return err } // Migration for timeout_seconds column if older DB exists _, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10") // Migration for rules table schema if older table exists var testCol string err := d.QueryRow("SELECT trigger FROM rules LIMIT 1").Scan(&testCol) if err != nil && err != sql.ErrNoRows { _, _ = d.Exec("DROP TABLE IF EXISTS rules") _, _ = d.Exec(` CREATE TABLE IF NOT EXISTS rules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, trigger TEXT NOT NULL, conditions TEXT DEFAULT '[]', match_mode TEXT DEFAULT 'all', actions TEXT DEFAULT '[]', is_active BOOLEAN DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); `) } return nil } // User operations func (d *DB) CountUsers() (int, error) { var count int err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) return count, err } func (d *DB) GetUserByUsername(username string) (*models.User, error) { var u models.User err := d.QueryRow( "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?", username, ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } return &u, nil } func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) { now := time.Now().UTC() u := &models.User{ ID: uuid.New().String(), Username: username, PasswordHash: passwordHash, CreatedAt: now, UpdatedAt: now, } _, err := d.Exec( "INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", u.ID, u.Username, u.PasswordHash, u.CreatedAt, u.UpdatedAt, ) if err != nil { return nil, err } return u, nil } func (d *DB) UpdateUserPassword(userID, passwordHash string) error { now := time.Now().UTC() _, err := d.Exec( "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", passwordHash, now, userID, ) return err } // Category operations func (d *DB) ListCategories() ([]models.Category, error) { query := ` SELECT c.id, c.title, c.parent_id, c.sort_order, c.created_at, COUNT(f.id) as feed_count FROM categories c LEFT JOIN feeds f ON f.category_id = c.id GROUP BY c.id ORDER BY c.sort_order ASC, c.title ASC ` rows, err := d.Query(query) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var categories []models.Category for rows.Next() { var c models.Category var parentID sql.NullString if err := rows.Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt, &c.FeedCount); err != nil { return nil, err } if parentID.Valid { p := parentID.String c.ParentID = &p } categories = append(categories, c) } return categories, nil } func (d *DB) GetCategoryByTitle(title string) (*models.Category, error) { var c models.Category var parentID sql.NullString err := d.QueryRow("SELECT id, title, parent_id, sort_order, created_at FROM categories WHERE title = ?", title). Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt) if err != nil { return nil, err } if parentID.Valid { p := parentID.String c.ParentID = &p } return &c, nil } func (d *DB) CreateCategory(title string, parentID *string) (*models.Category, error) { c := &models.Category{ ID: uuid.New().String(), Title: title, ParentID: parentID, SortOrder: 0, CreatedAt: time.Now().UTC(), } var pID interface{} if parentID != nil { pID = *parentID } _, err := d.Exec("INSERT INTO categories (id, title, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)", c.ID, c.Title, pID, c.SortOrder, c.CreatedAt) if err != nil { return nil, err } return c, nil } func (d *DB) DeleteCategory(id string) error { _, err := d.Exec("DELETE FROM categories WHERE id = ?", id) return err } // Feed operations func (d *DB) ListFeeds() ([]models.Feed, error) { query := ` SELECT f.id, f.title, f.feed_url, f.site_url, f.category_id, COALESCE(c.title, '') as category_title, f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10), f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at, COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count, COUNT(e.id) as total_count FROM feeds f LEFT JOIN categories c ON c.id = f.category_id LEFT JOIN feed_entries e ON e.feed_id = f.id GROUP BY f.id ORDER BY f.title ASC ` rows, err := d.Query(query) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var feeds []models.Feed for rows.Next() { var f models.Feed var catID, siteURL, etag, lastMod, fetchErr sql.NullString var lastFetched sql.NullTime if err := rows.Scan( &f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle, &f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount, ); err != nil { return nil, err } if catID.Valid { cid := catID.String f.CategoryID = &cid } if siteURL.Valid { f.SiteURL = siteURL.String } if etag.Valid { f.ETag = etag.String } if lastMod.Valid { f.LastModified = lastMod.String } if lastFetched.Valid { t := lastFetched.Time f.LastFetchedAt = &t } if fetchErr.Valid { f.FetchError = fetchErr.String } feeds = append(feeds, f) } return feeds, nil } func (d *DB) GetFeed(id string) (*models.Feed, error) { query := ` SELECT f.id, f.title, f.feed_url, f.site_url, f.category_id, COALESCE(c.title, '') as category_title, f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10), f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at, COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count, COUNT(e.id) as total_count FROM feeds f LEFT JOIN categories c ON c.id = f.category_id LEFT JOIN feed_entries e ON e.feed_id = f.id WHERE f.id = ? GROUP BY f.id ` var f models.Feed var catID, siteURL, etag, lastMod, fetchErr sql.NullString var lastFetched sql.NullTime err := d.QueryRow(query, id).Scan( &f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle, &f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount, ) if err != nil { return nil, err } if catID.Valid { cid := catID.String f.CategoryID = &cid } if siteURL.Valid { f.SiteURL = siteURL.String } if etag.Valid { f.ETag = etag.String } if lastMod.Valid { f.LastModified = lastMod.String } if lastFetched.Valid { t := lastFetched.Time f.LastFetchedAt = &t } if fetchErr.Valid { f.FetchError = fetchErr.String } return &f, nil } func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) { var f models.Feed var catID, siteURL, etag, lastMod, fetchErr sql.NullString var lastFetched sql.NullTime err := d.QueryRow( "SELECT id, title, feed_url, site_url, category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?", feedURL, ).Scan( &f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, ) if err != nil { return nil, err } if catID.Valid { cid := catID.String f.CategoryID = &cid } if siteURL.Valid { f.SiteURL = siteURL.String } return &f, nil } func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*models.Feed, error) { now := time.Now().UTC() f := &models.Feed{ ID: uuid.New().String(), Title: title, FeedURL: feedURL, SiteURL: siteURL, CategoryID: categoryID, RefreshIntervalMinutes: 30, TimeoutSeconds: 10, CreatedAt: now, } var catID interface{} if categoryID != nil { catID = *categoryID } _, err := d.Exec( "INSERT INTO feeds (id, title, feed_url, site_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", f.ID, f.Title, f.FeedURL, f.SiteURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt, ) if err != nil { return nil, err } return f, nil } func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error { feed, err := d.GetFeed(id) if err != nil { return err } title := feed.Title if req.Title != nil && *req.Title != "" { title = *req.Title } catID := feed.CategoryID if req.CategoryID != nil { if *req.CategoryID == "" { catID = nil } else { catID = req.CategoryID } } interval := feed.RefreshIntervalMinutes if req.RefreshIntervalMinutes != nil && *req.RefreshIntervalMinutes > 0 { interval = *req.RefreshIntervalMinutes } timeout := feed.TimeoutSeconds if req.TimeoutSeconds != nil && *req.TimeoutSeconds > 0 { timeout = *req.TimeoutSeconds } var cID interface{} if catID != nil { cID = *catID } _, err = d.Exec(` UPDATE feeds SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ? WHERE id = ? `, title, cID, interval, timeout, id) return err } func (d *DB) UpdateFeedFetchStatus(id string, etag, lastModified, fetchErr string) error { now := time.Now().UTC() _, err := d.Exec( "UPDATE feeds SET etag = ?, last_modified = ?, fetch_error = ?, last_fetched_at = ? WHERE id = ?", etag, lastModified, fetchErr, now, id, ) return err } func (d *DB) DeleteFeed(id string) error { _, err := d.Exec("DELETE FROM feeds WHERE id = ?", id) return err } // Feed Entries operations func (d *DB) UpsertFeedEntries(feedID string, entries []models.FeedEntry) ([]models.FeedEntry, error) { if len(entries) == 0 { return nil, nil } var newEntries []models.FeedEntry now := time.Now().UTC() insertStmt, err := d.Prepare(` INSERT INTO feed_entries ( id, feed_id, guid, url, title, author, content, raw_content, published_at, is_read, is_starred, metadata, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) `) if err != nil { return nil, err } defer func() { _ = insertStmt.Close() }() updateStmt, err := d.Prepare(` UPDATE feed_entries SET title = ?, url = ?, author = ?, content = ?, raw_content = ? WHERE feed_id = ? AND guid = ? `) if err != nil { return nil, err } defer func() { _ = updateStmt.Close() }() for _, e := range entries { var existingID string err := d.QueryRow("SELECT id FROM feed_entries WHERE feed_id = ? AND guid = ?", feedID, e.GUID).Scan(&existingID) switch err { case sql.ErrNoRows: e.ID = uuid.New().String() e.FeedID = feedID pubAt := e.PublishedAt if pubAt.IsZero() { pubAt = now } metaBytes, _ := json.Marshal(e.Metadata) if len(metaBytes) == 0 { metaBytes = []byte("{}") } _, err := insertStmt.Exec( e.ID, feedID, e.GUID, e.URL, e.Title, e.Author, e.Content, e.RawContent, pubAt, string(metaBytes), now, ) if err == nil { e.CreatedAt = now newEntries = append(newEntries, e) } case nil: _, _ = updateStmt.Exec(e.Title, e.URL, e.Author, e.Content, e.RawContent, feedID, e.GUID) } } return newEntries, nil } type EntryFilter struct { View string // "inbox" (unread), "starred", "all" FeedID string CategoryID string Search string Limit int Offset int } func (d *DB) GetEntry(id string) (*models.FeedEntry, error) { 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.id = ? ` var e models.FeedEntry var author, rawContent, shareUUID, metaStr sql.NullString err := d.QueryRow(query, id).Scan( &e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL, &e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent, &e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt, ) if err != nil { return nil, err } if author.Valid { e.Author = author.String } if rawContent.Valid { e.RawContent = rawContent.String } if shareUUID.Valid { s := shareUUID.String e.ShareUUID = &s } e.Metadata = make(map[string]interface{}) if metaStr.Valid && metaStr.String != "" { _ = json.Unmarshal([]byte(metaStr.String), &e.Metadata) } // Load variants variants, _ := d.ListVariantsForEntry(e.ID) e.Variants = variants return &e, nil } func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) { var whereClauses []string var args []interface{} switch filter.View { case "inbox": whereClauses = append(whereClauses, "e.is_read = 0") case "starred": whereClauses = append(whereClauses, "e.is_starred = 1") } if filter.FeedID != "" { whereClauses = append(whereClauses, "e.feed_id = ?") args = append(args, filter.FeedID) } if filter.CategoryID != "" { whereClauses = append(whereClauses, "f.category_id = ?") args = append(args, filter.CategoryID) } if filter.Search != "" { whereClauses = append(whereClauses, "(e.title LIKE ? OR e.content LIKE ? OR e.author LIKE ?)") pattern := "%" + filter.Search + "%" args = append(args, pattern, pattern, pattern) } whereSQL := "" if len(whereClauses) > 0 { whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") } countQuery := fmt.Sprintf(` SELECT COUNT(e.id) FROM feed_entries e JOIN feeds f ON f.id = e.feed_id %s `, whereSQL) var total int err := d.QueryRow(countQuery, args...).Scan(&total) if err != nil { return nil, 0, err } limit := filter.Limit if limit <= 0 || limit > 500 { limit = 100 } offset := filter.Offset if offset < 0 { offset = 0 } query := fmt.Sprintf(` 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 %s ORDER BY e.published_at DESC, e.created_at DESC LIMIT ? OFFSET ? `, whereSQL) queryArgs := append(args, limit, offset) rows, err := d.Query(query, queryArgs...) if err != nil { return nil, 0, err } defer func() { _ = rows.Close() }() var entries []models.FeedEntry for rows.Next() { var e models.FeedEntry var author, rawContent, shareUUID, metaStr sql.NullString err := rows.Scan( &e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL, &e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent, &e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt, ) if err != nil { return nil, 0, err } if author.Valid { e.Author = author.String } if rawContent.Valid { e.RawContent = rawContent.String } if shareUUID.Valid { s := shareUUID.String e.ShareUUID = &s } e.Metadata = make(map[string]interface{}) if metaStr.Valid && metaStr.String != "" { _ = json.Unmarshal([]byte(metaStr.String), &e.Metadata) } entries = append(entries, e) } // Batch-load variants for all returned entries if len(entries) > 0 { ids := make([]string, len(entries)) for i, e := range entries { ids[i] = e.ID } variantMap, err := d.batchLoadVariants(ids) if err == nil { for i := range entries { if v, ok := variantMap[entries[i].ID]; ok { entries[i].Variants = v } } } } return entries, total, nil } func (d *DB) UpdateEntryRead(id string, isRead bool) error { _, err := d.Exec("UPDATE feed_entries SET is_read = ? WHERE id = ?", isRead, id) return err } func (d *DB) UpdateEntryStarred(id string, isStarred bool) error { _, err := d.Exec("UPDATE feed_entries SET is_starred = ? WHERE id = ?", isStarred, id) return err } func (d *DB) MarkAllRead(feedID, categoryID *string) error { if feedID != nil && *feedID != "" { _, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID) return err } if categoryID != nil && *categoryID != "" { _, err := d.Exec(` UPDATE feed_entries SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE category_id = ?) `, *categoryID) return err } _, err := d.Exec("UPDATE feed_entries SET is_read = 1") return err } // Entry Variants CRUD func (d *DB) GetVariant(entryID, key string) (*models.EntryVariant, error) { query := ` SELECT entry_id, variant_key, content_type, COALESCE(text_value, ''), COALESCE(blob_path, ''), metadata, created_at, updated_at FROM entry_variants WHERE entry_id = ? AND variant_key = ? ` var v models.EntryVariant var metaStr sql.NullString err := d.QueryRow(query, entryID, key).Scan( &v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt, ) if err != nil { return nil, err } v.Metadata = make(map[string]interface{}) if metaStr.Valid && metaStr.String != "" { _ = json.Unmarshal([]byte(metaStr.String), &v.Metadata) } return &v, nil } func (d *DB) ListVariantsForEntry(entryID string) (map[string]models.EntryVariant, error) { query := ` SELECT entry_id, variant_key, content_type, COALESCE(text_value, ''), COALESCE(blob_path, ''), metadata, created_at, updated_at FROM entry_variants WHERE entry_id = ? ` rows, err := d.Query(query, entryID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() variants := make(map[string]models.EntryVariant) for rows.Next() { var v models.EntryVariant var metaStr sql.NullString err := rows.Scan( &v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt, ) if err != nil { return nil, err } v.Metadata = make(map[string]interface{}) if metaStr.Valid && metaStr.String != "" { _ = json.Unmarshal([]byte(metaStr.String), &v.Metadata) } variants[v.VariantKey] = v } return variants, nil } // batchLoadVariants loads lightweight variants (tags, ai_summary keys, etc.) for multiple entries. // Excludes large text variants like readability_full_text for performance. func (d *DB) batchLoadVariants(entryIDs []string) (map[string]map[string]models.EntryVariant, error) { if len(entryIDs) == 0 { return nil, nil } placeholders := make([]string, len(entryIDs)) args := make([]interface{}, len(entryIDs)) for i, id := range entryIDs { placeholders[i] = "?" args[i] = id } query := ` SELECT entry_id, variant_key, content_type, CASE WHEN variant_key IN ('readability_full_text') THEN '' ELSE COALESCE(text_value, '') END, COALESCE(blob_path, ''), metadata, created_at, updated_at FROM entry_variants WHERE entry_id IN (` + strings.Join(placeholders, ",") + `) ` rows, err := d.Query(query, args...) if err != nil { return nil, err } defer func() { _ = rows.Close() }() result := make(map[string]map[string]models.EntryVariant) for rows.Next() { var v models.EntryVariant var metaStr sql.NullString err := rows.Scan( &v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt, ) if err != nil { return nil, err } v.Metadata = make(map[string]interface{}) if metaStr.Valid && metaStr.String != "" { _ = json.Unmarshal([]byte(metaStr.String), &v.Metadata) } if result[v.EntryID] == nil { result[v.EntryID] = make(map[string]models.EntryVariant) } result[v.EntryID][v.VariantKey] = v } return result, nil } func (d *DB) SetVariant(v models.EntryVariant) error { now := time.Now().UTC() metaBytes, _ := json.Marshal(v.Metadata) if len(metaBytes) == 0 { metaBytes = []byte("{}") } query := ` INSERT INTO entry_variants ( entry_id, variant_key, content_type, text_value, blob_path, metadata, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(entry_id, variant_key) DO UPDATE SET content_type = excluded.content_type, text_value = excluded.text_value, blob_path = excluded.blob_path, metadata = excluded.metadata, updated_at = excluded.updated_at ` _, err := d.Exec(query, v.EntryID, v.VariantKey, v.ContentType, v.TextValue, v.BlobPath, string(metaBytes), now, now) return err } func (d *DB) DeleteVariant(entryID, key string) error { _, err := d.Exec("DELETE FROM entry_variants WHERE entry_id = ? AND variant_key = ?", entryID, key) return err } // Rules CRUD func (d *DB) ListRules() ([]models.Rule, error) { query := ` SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at FROM rules ORDER BY created_at ASC ` rows, err := d.Query(query) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var rules []models.Rule for rows.Next() { var r models.Rule var condsStr, actionsStr string err := rows.Scan( &r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt, ) if err != nil { return nil, err } _ = json.Unmarshal([]byte(condsStr), &r.Conditions) _ = json.Unmarshal([]byte(actionsStr), &r.Actions) rules = append(rules, r) } return rules, nil } func (d *DB) GetActiveRules(trigger string) ([]models.Rule, error) { query := ` SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at FROM rules WHERE is_active = 1 AND (trigger = ? OR trigger = '*') ORDER BY created_at ASC ` rows, err := d.Query(query, trigger) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var rules []models.Rule for rows.Next() { var r models.Rule var condsStr, actionsStr string err := rows.Scan( &r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt, ) if err != nil { return nil, err } _ = json.Unmarshal([]byte(condsStr), &r.Conditions) _ = json.Unmarshal([]byte(actionsStr), &r.Actions) rules = append(rules, r) } return rules, nil } func (d *DB) GetRule(id string) (*models.Rule, error) { query := ` SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at FROM rules WHERE id = ? ` var r models.Rule var condsStr, actionsStr string err := d.QueryRow(query, id).Scan( &r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt, ) if err != nil { return nil, err } _ = json.Unmarshal([]byte(condsStr), &r.Conditions) _ = json.Unmarshal([]byte(actionsStr), &r.Actions) return &r, nil } func (d *DB) CreateRule(name, trigger, matchMode string, conditions []models.RuleCondition, actions []models.RuleAction) (*models.Rule, error) { id := uuid.New().String() now := time.Now().UTC() if matchMode == "" { matchMode = "all" } condsBytes, _ := json.Marshal(conditions) actionsBytes, _ := json.Marshal(actions) query := ` INSERT INTO rules ( id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) ` _, err := d.Exec(query, id, name, trigger, string(condsBytes), matchMode, string(actionsBytes), now, now) if err != nil { return nil, err } return &models.Rule{ ID: id, Name: name, Trigger: trigger, Conditions: conditions, MatchMode: matchMode, Actions: actions, IsActive: true, CreatedAt: now, UpdatedAt: now, }, nil } func (d *DB) UpdateRule(id string, name, trigger, matchMode string, conditions []models.RuleCondition, actions []models.RuleAction, isActive bool) (*models.Rule, error) { now := time.Now().UTC() if matchMode == "" { matchMode = "all" } condsBytes, _ := json.Marshal(conditions) actionsBytes, _ := json.Marshal(actions) query := ` UPDATE rules SET name = ?, trigger = ?, conditions = ?, match_mode = ?, actions = ?, is_active = ?, updated_at = ? WHERE id = ? ` res, err := d.Exec(query, name, trigger, string(condsBytes), matchMode, string(actionsBytes), isActive, now, id) if err != nil { return nil, err } if n, _ := res.RowsAffected(); n == 0 { return nil, fmt.Errorf("rule not found") } return d.GetRule(id) } func (d *DB) DeleteRule(id string) error { _, err := d.Exec("DELETE FROM rules WHERE id = ?", id) return err } func (d *DB) GetStats() (*models.Stats, error) { var s models.Stats err := d.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&s.TotalFeeds) if err != nil { return nil, err } err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_read = 0").Scan(&s.TotalUnread) if err != nil { return nil, err } err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_starred = 1").Scan(&s.TotalStarred) if err != nil { return nil, err } err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories) if err != nil { return nil, err } _ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules) return &s, nil } // System Settings CRUD func (d *DB) GetSetting(key string) (string, error) { var val string err := d.QueryRow("SELECT value FROM system_settings WHERE key = ?", key).Scan(&val) if err != nil { if err == sql.ErrNoRows { return "", nil } return "", err } return val, nil } func (d *DB) SetSetting(key, value string) error { now := time.Now().UTC() _, err := d.Exec(` INSERT INTO system_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at `, key, value, now) return err } func (d *DB) GetSettingsMap() (map[string]string, error) { rows, err := d.Query("SELECT key, value FROM system_settings") if err != nil { return nil, err } defer func() { _ = rows.Close() }() res := make(map[string]string) for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, err } res[k] = v } return res, nil }