Files
rsspipe/internal/db/db.go
T
haopengzhan dfbf646332
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 47s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 1m19s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 3h11m47s
feat: implement AI integration with backend configuration and rule-based enrichment actions
2026-08-26 22:42:23 -07:00

1119 lines
29 KiB
Go

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
}