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.
263 lines
6.6 KiB
Go
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)
|
|
}
|