150 lines
3.5 KiB
Go
150 lines
3.5 KiB
Go
// ©AngelaMos | 2026
|
|
// store.go
|
|
|
|
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"modernc.org/sqlite"
|
|
sqlite3 "modernc.org/sqlite/lib"
|
|
)
|
|
|
|
var ErrDuplicate = errors.New("store: article already exists")
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
version int
|
|
}
|
|
|
|
type SourceInput struct {
|
|
Name string
|
|
Title string
|
|
URL string
|
|
Type string
|
|
Weight float64
|
|
Tags []string
|
|
Enabled bool
|
|
}
|
|
|
|
type SourceRow struct {
|
|
ID int64
|
|
Name string
|
|
Title string
|
|
URL string
|
|
Type string
|
|
Weight float64
|
|
Tags []string
|
|
Enabled bool
|
|
}
|
|
|
|
type Article struct {
|
|
SourceID int64
|
|
CanonicalURL string
|
|
ContentHash string
|
|
Title string
|
|
Summary string
|
|
Body string
|
|
Author string
|
|
PublishedAt int64
|
|
FetchedAt int64
|
|
}
|
|
|
|
func Open(path string) (*Store, error) {
|
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite %s: %w", path, err)
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("ping sqlite %s: %w", path, err)
|
|
}
|
|
version, err := migrate(db)
|
|
if err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return &Store{db: db, version: version}, nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
func (s *Store) Version() int { return s.version }
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
func (s *Store) UpsertSource(in SourceInput) (int64, error) {
|
|
tags := strings.Join(in.Tags, ",")
|
|
var id int64
|
|
err := s.db.QueryRow(`
|
|
INSERT INTO sources (name, title, url, type, weight, tags, enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
title = excluded.title, url = excluded.url, type = excluded.type,
|
|
weight = excluded.weight, tags = excluded.tags, enabled = excluded.enabled
|
|
RETURNING id`,
|
|
in.Name, in.Title, in.URL, in.Type, in.Weight, tags, boolToInt(in.Enabled),
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("upsert source %q: %w", in.Name, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Store) GetSourceByName(name string) (SourceRow, error) {
|
|
var r SourceRow
|
|
var tags string
|
|
var enabled int
|
|
err := s.db.QueryRow(`
|
|
SELECT id, name, title, url, type, weight, tags, enabled
|
|
FROM sources WHERE name = ?`, name,
|
|
).Scan(&r.ID, &r.Name, &r.Title, &r.URL, &r.Type, &r.Weight, &tags, &enabled)
|
|
if err != nil {
|
|
return SourceRow{}, fmt.Errorf("get source %q: %w", name, err)
|
|
}
|
|
if tags != "" {
|
|
r.Tags = strings.Split(tags, ",")
|
|
}
|
|
r.Enabled = enabled != 0
|
|
return r, nil
|
|
}
|
|
|
|
func (s *Store) InsertArticle(a Article) (int64, error) {
|
|
res, err := s.db.Exec(`
|
|
INSERT INTO articles
|
|
(source_id, canonical_url, content_hash, title, summary, body, author, published_at, fetched_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
a.SourceID, a.CanonicalURL, a.ContentHash, a.Title, a.Summary, a.Body,
|
|
a.Author, a.PublishedAt, a.FetchedAt,
|
|
)
|
|
if err != nil {
|
|
var se *sqlite.Error
|
|
if errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
|
|
return 0, ErrDuplicate
|
|
}
|
|
return 0, fmt.Errorf("insert article %q: %w", a.CanonicalURL, err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("insert article %q: last insert id: %w", a.CanonicalURL, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Store) CountArticles() (int, error) {
|
|
var n int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM articles`).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("count articles: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|