321 lines
9.0 KiB
Go
321 lines
9.0 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type Alarm struct {
|
|
ID int
|
|
Name string
|
|
Description string
|
|
Time string // HH:MM format for simple alarms
|
|
Trigger string // Cron expression or "once"
|
|
SoundPath string
|
|
Enabled bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
LastTriggered *time.Time
|
|
SnoozeCount int
|
|
}
|
|
|
|
type AlarmHistory struct {
|
|
ID int
|
|
AlarmID int
|
|
TriggeredAt time.Time
|
|
DismissedAt *time.Time
|
|
SnoozedCount int
|
|
}
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func Open() (*Store, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get home dir: %w", err)
|
|
}
|
|
|
|
dir := filepath.Join(home, ".config", "woke")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(dir, "woke.db")
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
|
|
store := &Store{db: db}
|
|
if err := store.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
return store, nil
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
func (s *Store) migrate() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS alarms (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
time TEXT DEFAULT '',
|
|
trigger TEXT NOT NULL,
|
|
sound_path TEXT NOT NULL DEFAULT 'default',
|
|
enabled BOOLEAN DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
last_triggered TIMESTAMP,
|
|
snooze_count INTEGER DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS alarm_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
alarm_id INTEGER,
|
|
triggered_at TIMESTAMP,
|
|
dismissed_at TIMESTAMP,
|
|
snoozed_count INTEGER DEFAULT 0,
|
|
FOREIGN KEY (alarm_id) REFERENCES alarms(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ListAlarms() ([]Alarm, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT id, name, description, time, trigger, sound_path, enabled,
|
|
created_at, updated_at, last_triggered, snooze_count
|
|
FROM alarms ORDER BY time ASC, name ASC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var alarms []Alarm
|
|
for rows.Next() {
|
|
var a Alarm
|
|
var lastTriggered sql.NullTime
|
|
err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Time, &a.Trigger,
|
|
&a.SoundPath, &a.Enabled, &a.CreatedAt, &a.UpdatedAt,
|
|
&lastTriggered, &a.SnoozeCount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lastTriggered.Valid {
|
|
a.LastTriggered = &lastTriggered.Time
|
|
}
|
|
alarms = append(alarms, a)
|
|
}
|
|
return alarms, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetAlarm(id int) (Alarm, error) {
|
|
var a Alarm
|
|
var lastTriggered sql.NullTime
|
|
err := s.db.QueryRow(`
|
|
SELECT id, name, description, time, trigger, sound_path, enabled,
|
|
created_at, updated_at, last_triggered, snooze_count
|
|
FROM alarms WHERE id = ?
|
|
`, id).Scan(&a.ID, &a.Name, &a.Description, &a.Time, &a.Trigger,
|
|
&a.SoundPath, &a.Enabled, &a.CreatedAt, &a.UpdatedAt,
|
|
&lastTriggered, &a.SnoozeCount)
|
|
if lastTriggered.Valid {
|
|
a.LastTriggered = &lastTriggered.Time
|
|
}
|
|
return a, err
|
|
}
|
|
|
|
func (s *Store) CreateAlarm(a Alarm) (int, error) {
|
|
result, err := s.db.Exec(`
|
|
INSERT INTO alarms (name, description, time, trigger, sound_path, enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, a.Name, a.Description, a.Time, a.Trigger, a.SoundPath, a.Enabled)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
id, err := result.LastInsertId()
|
|
return int(id), err
|
|
}
|
|
|
|
func (s *Store) UpdateAlarm(a Alarm) error {
|
|
_, err := s.db.Exec(`
|
|
UPDATE alarms SET name=?, description=?, time=?, trigger=?, sound_path=?,
|
|
enabled=?, updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=?
|
|
`, a.Name, a.Description, a.Time, a.Trigger, a.SoundPath, a.Enabled, a.ID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteAlarm(id int) error {
|
|
_, err := s.db.Exec(`DELETE FROM alarms WHERE id=?`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) ToggleAlarm(id int) error {
|
|
_, err := s.db.Exec(`
|
|
UPDATE alarms SET enabled = NOT enabled, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
|
`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) MarkTriggered(id int) error {
|
|
now := time.Now()
|
|
_, err := s.db.Exec(`
|
|
UPDATE alarms SET last_triggered = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
|
`, now, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO alarm_history (alarm_id, triggered_at) VALUES (?, ?)
|
|
`, id, now)
|
|
return err
|
|
}
|
|
|
|
// Settings holds all configurable app settings.
|
|
type Settings struct {
|
|
SnoozeMinutes int
|
|
TimeoutMinutes int
|
|
DefaultSound string
|
|
BlinkOnMs int
|
|
BlinkOffMs int
|
|
ColorClock string
|
|
ColorAlarm string
|
|
ShowSeconds bool
|
|
|
|
// Client/server mode
|
|
ServerURL string // If set, run as client connecting to this URL
|
|
ServerPassword string // Password for authenticating to remote server
|
|
APIPassword string // Password required for incoming API requests (host mode)
|
|
PollSeconds int // How often to poll server in client mode
|
|
|
|
// Crescendo volume control (PulseAudio)
|
|
CrescendoEnabled bool // Whether to gradually raise volume
|
|
CrescendoStartPct int // Starting volume percentage (0-100)
|
|
CrescendoEndPct int // Ending volume percentage (0-100)
|
|
CrescendoDurationS int // Seconds to ramp from start to end
|
|
|
|
// Callback script
|
|
CallbackScript string // Path to script called on alarm events (start, dismiss, snooze)
|
|
}
|
|
|
|
func DefaultSettings() Settings {
|
|
return Settings{
|
|
SnoozeMinutes: 5,
|
|
TimeoutMinutes: 5,
|
|
DefaultSound: "default",
|
|
BlinkOnMs: 1000,
|
|
BlinkOffMs: 500,
|
|
ColorClock: "#00FF88",
|
|
ColorAlarm: "#FF4444",
|
|
ShowSeconds: true,
|
|
ServerURL: "",
|
|
ServerPassword: "",
|
|
APIPassword: "",
|
|
PollSeconds: 5,
|
|
CrescendoEnabled: false,
|
|
CrescendoStartPct: 20,
|
|
CrescendoEndPct: 100,
|
|
CrescendoDurationS: 60,
|
|
CallbackScript: "",
|
|
}
|
|
}
|
|
|
|
func (s *Store) LoadSettings() Settings {
|
|
cfg := DefaultSettings()
|
|
cfg.SnoozeMinutes = s.getSettingInt("snooze_minutes", cfg.SnoozeMinutes)
|
|
cfg.TimeoutMinutes = s.getSettingInt("timeout_minutes", cfg.TimeoutMinutes)
|
|
cfg.DefaultSound = s.GetSetting("default_sound", cfg.DefaultSound)
|
|
cfg.BlinkOnMs = s.getSettingInt("blink_on_ms", cfg.BlinkOnMs)
|
|
cfg.BlinkOffMs = s.getSettingInt("blink_off_ms", cfg.BlinkOffMs)
|
|
cfg.ColorClock = s.GetSetting("color_clock", cfg.ColorClock)
|
|
cfg.ColorAlarm = s.GetSetting("color_alarm", cfg.ColorAlarm)
|
|
cfg.ShowSeconds = s.GetSetting("show_seconds", "true") == "true"
|
|
cfg.ServerURL = s.GetSetting("server_url", cfg.ServerURL)
|
|
cfg.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword)
|
|
cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword)
|
|
cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds)
|
|
cfg.CrescendoEnabled = s.GetSetting("crescendo_enabled", "false") == "true"
|
|
cfg.CrescendoStartPct = s.getSettingInt("crescendo_start_pct", cfg.CrescendoStartPct)
|
|
cfg.CrescendoEndPct = s.getSettingInt("crescendo_end_pct", cfg.CrescendoEndPct)
|
|
cfg.CrescendoDurationS = s.getSettingInt("crescendo_duration_s", cfg.CrescendoDurationS)
|
|
cfg.CallbackScript = s.GetSetting("callback_script", cfg.CallbackScript)
|
|
return cfg
|
|
}
|
|
|
|
func (s *Store) SaveSettings(cfg Settings) error {
|
|
pairs := map[string]string{
|
|
"snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes),
|
|
"timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes),
|
|
"default_sound": cfg.DefaultSound,
|
|
"blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs),
|
|
"blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs),
|
|
"color_clock": cfg.ColorClock,
|
|
"color_alarm": cfg.ColorAlarm,
|
|
"show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds),
|
|
"server_url": cfg.ServerURL,
|
|
"server_password": cfg.ServerPassword,
|
|
"api_password": cfg.APIPassword,
|
|
"poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds),
|
|
"crescendo_enabled": fmt.Sprintf("%t", cfg.CrescendoEnabled),
|
|
"crescendo_start_pct": fmt.Sprintf("%d", cfg.CrescendoStartPct),
|
|
"crescendo_end_pct": fmt.Sprintf("%d", cfg.CrescendoEndPct),
|
|
"crescendo_duration_s": fmt.Sprintf("%d", cfg.CrescendoDurationS),
|
|
"callback_script": cfg.CallbackScript,
|
|
}
|
|
for k, v := range pairs {
|
|
if err := s.SetSetting(k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) getSettingInt(key string, defaultVal int) int {
|
|
val := s.GetSetting(key, "")
|
|
if val == "" {
|
|
return defaultVal
|
|
}
|
|
var n int
|
|
if _, err := fmt.Sscanf(val, "%d", &n); err != nil {
|
|
return defaultVal
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *Store) GetSetting(key, defaultVal string) string {
|
|
var val string
|
|
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&val)
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
return val
|
|
}
|
|
|
|
func (s *Store) SetSetting(key, value string) error {
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO settings (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
`, key, value)
|
|
return err
|
|
}
|