1119 lines
29 KiB
Go
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
|
|
}
|