Files
lazyntfy/internal/history/history.go
T
ahp 97cc150bf7 feat(history): persist topics and messages in SQLite
Store topics, messages, and small UI preferences in $XDG_DATA_HOME/lazyntfy/history.db. Each topic keeps its newest 1000 messages, expired rows are pruned on startup, and credentials are never written. Saves use a single transaction and prune only when a topic is over the cap.
2026-10-11 12:04:53 +03:30

263 lines
6.6 KiB
Go

// Package history stores topics and messages on disk so they survive restarts.
// Credentials are not written.
package history
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
"lazyntfy/internal/config"
"lazyntfy/internal/store"
)
const maxPerTopic = 1000
// DB is the on-disk history.
type DB struct {
sql *sql.DB
}
// DefaultPath is $XDG_DATA_HOME/lazyntfy/history.db, or ~/.local/share/lazyntfy/history.db.
func DefaultPath() string {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".local", "share")
}
return filepath.Join(base, "lazyntfy", "history.db")
}
// Open creates the database and its tables.
func Open(path string) (*DB, error) {
if path == "" {
return nil, fmt.Errorf("empty history path")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(`
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS topics (
origin TEXT NOT NULL,
name TEXT NOT NULL,
display TEXT NOT NULL DEFAULT '',
PRIMARY KEY (origin, name)
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
origin TEXT NOT NULL DEFAULT '',
topic TEXT NOT NULL,
time INTEGER NOT NULL,
expires INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
priority INTEGER NOT NULL DEFAULT 0,
click TEXT NOT NULL DEFAULT '',
attachment TEXT NOT NULL DEFAULT '',
actions TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS messages_topic_time ON messages (topic, time DESC);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`); err != nil {
_ = db.Close()
return nil, err
}
h := &DB{sql: db}
h.pruneExpired()
return h, nil
}
// Close releases the file.
func (d *DB) Close() error {
if d == nil || d.sql == nil {
return nil
}
return d.sql.Close()
}
// SaveTopic remembers a topic name. Auth is intentionally omitted.
func (d *DB) SaveTopic(sub config.Sub) error {
if d == nil || sub.Name == "" {
return nil
}
_, err := d.sql.Exec(`
INSERT INTO topics (origin, name, display) VALUES (?, ?, ?)
ON CONFLICT (origin, name) DO UPDATE SET display = excluded.display
`, sub.Origin, sub.Name, sub.Display)
return err
}
// Topics returns every remembered topic, newest names last as stored.
func (d *DB) Topics() ([]config.Sub, error) {
if d == nil {
return nil, nil
}
rows, err := d.sql.Query(`SELECT origin, name, display FROM topics ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []config.Sub
for rows.Next() {
var sub config.Sub
if err := rows.Scan(&sub.Origin, &sub.Name, &sub.Display); err != nil {
return nil, err
}
out = append(out, sub)
}
return out, rows.Err()
}
// SaveMessage inserts or replaces one message and trims the topic to the cap.
func (d *DB) SaveMessage(origin string, m store.Message) error {
if d == nil || m.ID == "" || m.Topic == "" {
return nil
}
tags, err := json.Marshal(m.Tags)
if err != nil {
tags = []byte("[]")
}
attachment := ""
if m.Attachment != nil {
raw, err := json.Marshal(m.Attachment)
if err == nil {
attachment = string(raw)
}
}
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`
INSERT INTO messages (id, origin, topic, time, expires, title, body, tags, priority, click, attachment, actions)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
body = excluded.body,
tags = excluded.tags,
priority = excluded.priority,
click = excluded.click,
attachment = excluded.attachment,
actions = excluded.actions
`, m.ID, origin, m.Topic, m.Time, m.Expires, m.Title, m.Body, string(tags), m.Priority, m.Click, attachment, m.Actions); err != nil {
return err
}
var kept int
if err := tx.QueryRow(`SELECT COUNT(*) FROM messages WHERE topic = ?`, m.Topic).Scan(&kept); err != nil {
return err
}
if kept > maxPerTopic {
if _, err := tx.Exec(`
DELETE FROM messages WHERE topic = ? AND id NOT IN (
SELECT id FROM messages WHERE topic = ? ORDER BY time DESC LIMIT ?
)
`, m.Topic, m.Topic, maxPerTopic); err != nil {
return err
}
}
return tx.Commit()
}
// Messages returns up to limit messages for a topic, newest first.
func (d *DB) Messages(topic string, limit int) ([]store.Message, error) {
if d == nil || topic == "" {
return nil, nil
}
if limit <= 0 {
limit = maxPerTopic
}
rows, err := d.sql.Query(`
SELECT id, topic, time, expires, title, body, tags, priority, click, attachment, actions
FROM messages WHERE topic = ? ORDER BY time DESC LIMIT ?
`, topic, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.Message
for rows.Next() {
var m store.Message
var tags, attachment string
if err := rows.Scan(&m.ID, &m.Topic, &m.Time, &m.Expires, &m.Title, &m.Body, &tags, &m.Priority, &m.Click, &attachment, &m.Actions); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(tags), &m.Tags)
if attachment != "" {
var a store.Attachment
if err := json.Unmarshal([]byte(attachment), &a); err == nil && (a.Name != "" || a.URL != "") {
m.Attachment = &a
}
}
out = append(out, m)
}
return out, rows.Err()
}
// DeleteMessage removes one message.
func (d *DB) DeleteMessage(id string) error {
if d == nil || id == "" {
return nil
}
_, err := d.sql.Exec(`DELETE FROM messages WHERE id = ?`, id)
return err
}
// ClearTopic removes every message for a topic.
func (d *DB) ClearTopic(topic string) error {
if d == nil || topic == "" {
return nil
}
_, err := d.sql.Exec(`DELETE FROM messages WHERE topic = ?`, topic)
return err
}
// SetState stores a small UI preference, such as the last topic.
func (d *DB) SetState(key, value string) error {
if d == nil || key == "" {
return nil
}
_, err := d.sql.Exec(`
INSERT INTO state (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
`, key, value)
return err
}
// State reads a preference. ok is false when it was never set.
func (d *DB) State(key string) (string, bool) {
if d == nil || key == "" {
return "", false
}
var value string
err := d.sql.QueryRow(`SELECT value FROM state WHERE key = ?`, key).Scan(&value)
if err != nil {
return "", false
}
return value, true
}
func (d *DB) pruneExpired() {
now := time.Now().Unix()
_, _ = d.sql.Exec(`DELETE FROM messages WHERE expires > 0 AND expires < ?`, now)
}