Files
webnews/internal/storage/sqlite.go

558 lines
14 KiB
Go

package storage
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
type SQLiteDB struct {
db *sql.DB
mu sync.RWMutex
}
func NewSQLiteDB(path string) (*SQLiteDB, error) {
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
s := &SQLiteDB{db: db}
if err := s.init(); err != nil {
if closeErr := db.Close(); closeErr != nil {
return nil, fmt.Errorf("init error: %v, close error: %v", err, closeErr)
}
return nil, err
}
return s, nil
}
func (s *SQLiteDB) init() error {
s.mu.Lock()
defer s.mu.Unlock()
// Enable WAL mode
_, err := s.db.Exec("PRAGMA journal_mode=WAL;")
if err != nil {
return err
}
queries := []string{
`CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT,
"order" INTEGER
);`,
`CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
title TEXT,
categoryId TEXT,
"order" INTEGER,
enabled INTEGER,
fetchInterval INTEGER,
FOREIGN KEY(categoryId) REFERENCES categories(id)
);`,
`CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
feedId TEXT,
title TEXT,
link TEXT,
description TEXT,
content TEXT,
author TEXT,
pubDate INTEGER,
read INTEGER,
saved INTEGER,
imageUrl TEXT,
readAt INTEGER,
FOREIGN KEY(feedId) REFERENCES feeds(id)
);`,
`CREATE INDEX IF NOT EXISTS idx_articles_pubDate ON articles(pubDate);`,
`CREATE INDEX IF NOT EXISTS idx_articles_readAt ON articles(readAt);`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);`,
`CREATE TABLE IF NOT EXISTS caches (
key TEXT PRIMARY KEY,
value BLOB,
expiresAt INTEGER
);`,
`CREATE INDEX IF NOT EXISTS idx_caches_expiresAt ON caches(expiresAt);`,
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return err
}
}
return nil
}
func (s *SQLiteDB) Close() error {
return s.db.Close()
}
// Database Stats and Operations
type DBStats struct {
Size int64 `json:"size"`
Path string `json:"path"`
Articles int `json:"articles"`
Feeds int `json:"feeds"`
WALEnabled bool `json:"walEnabled"`
}
func (s *SQLiteDB) GetStats() (DBStats, error) {
var stats DBStats
// Get file size
var path string
err := s.db.QueryRow("PRAGMA database_list").Scan(interface{}(nil), interface{}(nil), &path)
if err != nil {
// Fallback if PRAGMA fails
return stats, err
}
stats.Path = path
fi, err := os.Stat(path)
if err == nil {
stats.Size = fi.Size()
}
// Count articles
if err := s.db.QueryRow("SELECT COUNT(*) FROM articles").Scan(&stats.Articles); err != nil {
log.Printf("Error counting articles: %v", err)
}
// Count feeds
if err := s.db.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&stats.Feeds); err != nil {
log.Printf("Error counting feeds: %v", err)
}
// Check WAL
var mode string
if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
log.Printf("Error checking journal mode: %v", err)
}
stats.WALEnabled = mode == "wal"
return stats, nil
}
func (s *SQLiteDB) Vacuum() error {
_, err := s.db.Exec("VACUUM")
return err
}
func (s *SQLiteDB) IntegrityCheck() (string, error) {
var res string
err := s.db.QueryRow("PRAGMA integrity_check").Scan(&res)
return res, err
}
// Data Operations
func (s *SQLiteDB) SaveSettings(settingsJSON string) error {
_, err := s.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('main', ?)", settingsJSON)
return err
}
func (s *SQLiteDB) GetSettings() (string, error) {
var val string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'main'").Scan(&val)
if err == sql.ErrNoRows {
return "{}", nil
}
return val, err
}
func (s *SQLiteDB) SaveCategories(catsJSON string) error {
var cats []struct {
ID string `json:"id"`
Name string `json:"name"`
Order int `json:"order"`
}
if err := json.Unmarshal([]byte(catsJSON), &cats); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, c := range cats {
_, err := tx.Exec("INSERT OR REPLACE INTO categories (id, name, \"order\") VALUES (?, ?, ?)", c.ID, c.Name, c.Order)
if err != nil {
return err
}
}
return tx.Commit()
}
func (s *SQLiteDB) GetCategories() (string, error) {
rows, err := s.db.Query("SELECT id, name, \"order\" FROM categories ORDER BY \"order\" ASC")
if err != nil {
return "[]", err
}
defer rows.Close()
var cats []map[string]any = []map[string]any{}
for rows.Next() {
var id, name string
var order int
if err := rows.Scan(&id, &name, &order); err != nil {
return "[]", err
}
cats = append(cats, map[string]any{"id": id, "name": name, "order": order})
}
b, _ := json.Marshal(cats)
return string(b), nil
}
func (s *SQLiteDB) SaveFeeds(feedsJSON string) error {
var feeds []struct {
ID string `json:"id"`
Title string `json:"title"`
CategoryID string `json:"categoryId"`
Order int `json:"order"`
Enabled bool `json:"enabled"`
FetchInterval int `json:"fetchInterval"`
}
if err := json.Unmarshal([]byte(feedsJSON), &feeds); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, f := range feeds {
enabled := 0
if f.Enabled {
enabled = 1
}
_, err := tx.Exec("INSERT OR REPLACE INTO feeds (id, title, categoryId, \"order\", enabled, fetchInterval) VALUES (?, ?, ?, ?, ?, ?)",
f.ID, f.Title, f.CategoryID, f.Order, enabled, f.FetchInterval)
if err != nil {
return err
}
}
return tx.Commit()
}
func (s *SQLiteDB) GetFeeds() (string, error) {
rows, err := s.db.Query("SELECT id, title, categoryId, \"order\", enabled, fetchInterval FROM feeds ORDER BY \"order\" ASC")
if err != nil {
return "[]", err
}
defer rows.Close()
var feeds []map[string]any = []map[string]any{}
for rows.Next() {
var id, title, categoryId string
var order, enabled, fetchInterval int
if err := rows.Scan(&id, &title, &categoryId, &order, &enabled, &fetchInterval); err != nil {
return "[]", err
}
feeds = append(feeds, map[string]any{
"id": id,
"title": title,
"categoryId": categoryId,
"order": order,
"enabled": enabled == 1,
"fetchInterval": fetchInterval,
})
}
b, _ := json.Marshal(feeds)
return string(b), nil
}
func (s *SQLiteDB) SaveArticles(articlesJSON string) error {
var articles []map[string]any
if err := json.Unmarshal([]byte(articlesJSON), &articles); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, a := range articles {
read := 0
if r, ok := a["read"].(bool); ok && r {
read = 1
}
saved := 0
if sa, ok := a["saved"].(bool); ok && sa {
saved = 1
}
_, err := tx.Exec(`INSERT OR REPLACE INTO articles
(id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a["id"], a["feedId"], a["title"], a["link"], a["description"], a["content"], a["author"], a["pubDate"], read, saved, a["imageUrl"], a["readAt"])
if err != nil {
return err
}
}
return tx.Commit()
}
func (s *SQLiteDB) GetArticles(feedId string, offset, limit int, categoryId string) (string, error) {
var rows *sql.Rows
var err error
if feedId != "" {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles WHERE feedId = ? ORDER BY pubDate DESC LIMIT ? OFFSET ?", feedId, limit, offset)
} else if categoryId != "" {
rows, err = s.db.Query(`
SELECT a.id, a.feedId, a.title, a.link, a.description, a.content, a.author, a.pubDate, a.read, a.saved, a.imageUrl, a.readAt
FROM articles a
JOIN feeds f ON a.feedId = f.id
WHERE f.categoryId = ?
ORDER BY a.pubDate DESC LIMIT ? OFFSET ?`, categoryId, limit, offset)
} else {
rows, err = s.db.Query("SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt FROM articles ORDER BY pubDate DESC LIMIT ? OFFSET ?", limit, offset)
}
if err != nil {
return "[]", err
}
defer rows.Close()
var articles []map[string]any = []map[string]any{}
for rows.Next() {
var id, feedId, title, link, description, content, author, imageUrl sql.NullString
var pubDate int64
var read, saved int
var readAt sql.NullInt64
err := rows.Scan(&id, &feedId, &title, &link, &description, &content, &author, &pubDate, &read, &saved, &imageUrl, &readAt)
if err != nil {
return "[]", err
}
a := map[string]any{
"id": id.String,
"feedId": feedId.String,
"title": title.String,
"link": link.String,
"description": description.String,
"content": content.String,
"author": author.String,
"pubDate": pubDate,
"read": read == 1,
"saved": saved == 1,
"imageUrl": imageUrl.String,
}
if readAt.Valid {
a["readAt"] = readAt.Int64
}
articles = append(articles, a)
}
b, _ := json.Marshal(articles)
return string(b), nil
}
func (s *SQLiteDB) SearchArticles(query string, limit int) (string, error) {
q := "%" + query + "%"
rows, err := s.db.Query(`SELECT id, feedId, title, link, description, content, author, pubDate, read, saved, imageUrl, readAt
FROM articles
WHERE title LIKE ? OR description LIKE ? OR content LIKE ?
ORDER BY pubDate DESC LIMIT ?`, q, q, q, limit)
if err != nil {
return "[]", err
}
defer rows.Close()
var articles []map[string]any = []map[string]any{}
for rows.Next() {
var id, feedId, title, link, description, content, author, imageUrl sql.NullString
var pubDate int64
var read, saved int
var readAt sql.NullInt64
err := rows.Scan(&id, &feedId, &title, &link, &description, &content, &author, &pubDate, &read, &saved, &imageUrl, &readAt)
if err != nil {
return "[]", err
}
a := map[string]any{
"id": id.String,
"feedId": feedId.String,
"title": title.String,
"link": link.String,
"description": description.String,
"content": content.String,
"author": author.String,
"pubDate": pubDate,
"read": read == 1,
"saved": saved == 1,
"imageUrl": imageUrl.String,
}
if readAt.Valid {
a["readAt"] = readAt.Int64
}
articles = append(articles, a)
}
b, _ := json.Marshal(articles)
return string(b), nil
}
func (s *SQLiteDB) UpdateArticle(articleJSON string) error {
var a map[string]any
if err := json.Unmarshal([]byte(articleJSON), &a); err != nil {
return err
}
read := 0
if r, ok := a["read"].(bool); ok && r {
read = 1
}
saved := 0
if sa, ok := a["saved"].(bool); ok && sa {
saved = 1
}
_, err := s.db.Exec(`UPDATE articles SET read = ?, saved = ?, readAt = ?, content = ? WHERE id = ?`,
read, saved, a["readAt"], a["content"], a["id"])
return err
}
func (s *SQLiteDB) MarkAsRead(id string) error {
now := time.Now().UnixMilli()
_, err := s.db.Exec(`UPDATE articles SET read = 1, readAt = ? WHERE id = ? AND read = 0`, now, id)
return err
}
func (s *SQLiteDB) DeleteFeed(feedId string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM articles WHERE feedId = ?", feedId); err != nil {
return err
}
if _, err := tx.Exec("DELETE FROM feeds WHERE id = ?", feedId); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteDB) PurgeOldContent(days int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
res, err := s.db.Exec("UPDATE articles SET content = NULL WHERE saved = 0 AND pubDate < ?", cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *SQLiteDB) ClearAll() error {
queries := []string{
"DELETE FROM articles",
"DELETE FROM feeds",
"DELETE FROM categories",
"DELETE FROM settings",
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return err
}
}
return nil
}
func (s *SQLiteDB) GetReadingHistory(days int) (string, error) {
cutoff := time.Now().AddDate(0, 0, -days).UnixMilli()
rows, err := s.db.Query(`
SELECT strftime('%Y-%m-%d', datetime(readAt/1000, 'unixepoch', 'localtime')) as date, COUNT(*) as count
FROM articles
WHERE read = 1 AND readAt > ?
GROUP BY date
ORDER BY date DESC`, cutoff)
if err != nil {
return "[]", err
}
defer rows.Close()
var history []map[string]any = []map[string]any{}
for rows.Next() {
var date string
var count int
if err := rows.Scan(&date, &count); err != nil {
continue
}
// Convert local date string back to local midnight timestamp for frontend
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
continue
}
history = append(history, map[string]any{
"date": t.UnixMilli(),
"count": count,
})
}
b, _ := json.Marshal(history)
return string(b), nil
}
func (s *SQLiteDB) SetCache(key string, value []byte, ttl time.Duration) error {
expiresAt := time.Now().Add(ttl).UnixMilli()
_, err := s.db.Exec("INSERT OR REPLACE INTO caches (key, value, expiresAt) VALUES (?, ?, ?)", key, value, expiresAt)
return err
}
func (s *SQLiteDB) GetCache(key string) ([]byte, error) {
var value []byte
var expiresAt int64
err := s.db.QueryRow("SELECT value, expiresAt FROM caches WHERE key = ?", key).Scan(&value, &expiresAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if time.Now().UnixMilli() > expiresAt {
_, _ = s.db.Exec("DELETE FROM caches WHERE key = ?", key)
return nil, nil
}
return value, nil
}
func (s *SQLiteDB) PurgeExpiredCaches() error {
now := time.Now().UnixMilli()
_, err := s.db.Exec("DELETE FROM caches WHERE expiresAt < ?", now)
return err
}