493 lines
13 KiB
Go
493 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"rhemawavetech.com/rhemawave/internal/logger"
|
|
)
|
|
|
|
var DB *gorm.DB
|
|
|
|
// configCache is an in-memory cache of config values to avoid repeated DB queries.
|
|
var configCache = struct {
|
|
sync.RWMutex
|
|
m map[string]string
|
|
}{m: make(map[string]string)}
|
|
|
|
type Config struct {
|
|
gorm.Model
|
|
Key string `gorm:"uniqueIndex"`
|
|
Value string
|
|
}
|
|
|
|
type Proclamations struct {
|
|
gorm.Model
|
|
Title string
|
|
Content string
|
|
Commercial bool
|
|
Selected bool
|
|
Favorite bool
|
|
Available bool
|
|
}
|
|
|
|
type Log struct {
|
|
gorm.Model
|
|
Timestamp string
|
|
Level string
|
|
Message string
|
|
}
|
|
|
|
func (Log) TableName() string {
|
|
return "log_entries"
|
|
}
|
|
|
|
type TOSSection struct {
|
|
gorm.Model
|
|
SectionNumber int `gorm:"uniqueIndex"`
|
|
Title string
|
|
Content string // raw HTML
|
|
}
|
|
|
|
var defaultConfigValues = map[string]string{
|
|
"home_dir": "",
|
|
"assets_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave
|
|
"log_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave/logs.log
|
|
"email_address": "",
|
|
"license_key": "",
|
|
"system_type": "home",
|
|
"system_capabilities": "",
|
|
"selected_audio": "audio/audio.wav",
|
|
"audio_device_name": "",
|
|
"application_font": "Roboto-Regular",
|
|
"init_font_size": "128",
|
|
"standard_font_size": "32",
|
|
"line_pattern": "fibonacci",
|
|
"last_volume": "128",
|
|
"audio_enabled": "true",
|
|
"tos_section_1_accepted": "false",
|
|
"tos_section_2_accepted": "false",
|
|
"tos_section_3_accepted": "false",
|
|
"tos_section_4_accepted": "false",
|
|
"tos_section_5_accepted": "false",
|
|
"tos_section_6_accepted": "false",
|
|
"tos_section_7_accepted": "false",
|
|
"tos_section_8_accepted": "false",
|
|
"tos_section_9_accepted": "false",
|
|
"tos_section_10_accepted": "false",
|
|
}
|
|
|
|
// InitDatabase initializes the SQLite database connection
|
|
// and performs auto-migration for all models
|
|
func InitDatabase(dbpath string) error {
|
|
// Create the directory for the database if it doesn't exist
|
|
dbDir := filepath.Dir(dbpath)
|
|
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
|
log.Printf("Error creating database directory %s: %v", dbDir, err)
|
|
return err
|
|
}
|
|
|
|
// Check if database file exists
|
|
_, err := os.Stat(dbpath)
|
|
dbExists := !os.IsNotExist(err)
|
|
|
|
if !dbExists {
|
|
log.Printf("Database file does not exist at %s, creating new database", dbpath)
|
|
}
|
|
|
|
dsn := fmt.Sprintf("file:%s", dbpath)
|
|
|
|
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
if err != nil {
|
|
log.Printf("Error opening database: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Get underlying sql.DB to set pragmas
|
|
sqlDB, err := DB.DB()
|
|
if err != nil {
|
|
logger.Error("Error getting underlying database connection: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Configure SQLite for reliability and performance
|
|
pragmas := []string{
|
|
"PRAGMA journal_mode=WAL", // Write-Ahead Logging for better concurrency
|
|
"PRAGMA synchronous=FULL", // Maximum durability
|
|
"PRAGMA foreign_keys=ON", // Enable foreign key constraints
|
|
"PRAGMA busy_timeout=5000", // Wait up to 5 seconds for locks
|
|
"PRAGMA temp_store=MEMORY", // Keep temp tables in memory
|
|
}
|
|
|
|
for _, pragma := range pragmas {
|
|
if _, err := sqlDB.Exec(pragma); err != nil {
|
|
logger.Error("Error setting pragma %s: %v", pragma, err)
|
|
return fmt.Errorf("failed to set pragma: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := sqlDB.Ping(); err != nil {
|
|
logger.Error("Error pinging database: %v", err)
|
|
return fmt.Errorf("database connection failed: %w", err)
|
|
}
|
|
|
|
log.Println("Database connected successfully")
|
|
|
|
// Auto-migrate models
|
|
err = DB.AutoMigrate(&Config{}, &Proclamations{}, &Log{}, &TOSSection{})
|
|
if err != nil {
|
|
logger.Error("Error migrating database: %v", err)
|
|
return err
|
|
}
|
|
log.Println("Database migrated successfully")
|
|
|
|
// Seed defaults
|
|
err = ensureDefaultsExist()
|
|
if err != nil {
|
|
logger.Error("Error seeding default configuration values: %v", err)
|
|
return err
|
|
}
|
|
err = seedDefaultProclamations()
|
|
if err != nil {
|
|
logger.Error("Error seeding default proclamations: %v", err)
|
|
return err
|
|
}
|
|
|
|
err = seedDefaultTOSSections()
|
|
if err != nil {
|
|
logger.Error("Error seeding default TOS sections: %v", err)
|
|
return err
|
|
}
|
|
|
|
log.Println("Database initialized successfully")
|
|
return nil
|
|
}
|
|
|
|
// seedDefaultProclamations reads proclamation text files from the assets directory
|
|
// and populates the database on first run
|
|
func seedDefaultProclamations() error {
|
|
var count int64
|
|
err := DB.Model(&Proclamations{}).Count(&count).Error
|
|
if err != nil {
|
|
logger.Error("Error counting proclamations: %v", err)
|
|
return err
|
|
}
|
|
|
|
// Only seed if table is empty
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
|
|
// Get the assets directory from config
|
|
assetsDir, err := GetConfigValue("assets_dir")
|
|
if err != nil {
|
|
logger.Error("Error getting assets_dir: %v", err)
|
|
return err
|
|
}
|
|
|
|
proclamationsBaseDir := assetsDir + "/proclamations"
|
|
|
|
// Process both home and commercial directories
|
|
subdirs := []string{"home", "commercial"}
|
|
totalSeeded := 0
|
|
|
|
for _, subdir := range subdirs {
|
|
proclamationsDir := proclamationsBaseDir + "/" + subdir
|
|
isCommercial := (subdir == "commercial")
|
|
|
|
// Read all .txt files from the subdirectory
|
|
files, err := os.ReadDir(proclamationsDir)
|
|
if err != nil {
|
|
logger.Error("Error reading proclamations directory %s: %v", proclamationsDir, err)
|
|
continue // Continue with other directories even if one fails
|
|
}
|
|
|
|
for _, file := range files {
|
|
if file.IsDir() || !strings.HasSuffix(file.Name(), ".txt") {
|
|
continue
|
|
}
|
|
|
|
filePath := proclamationsDir + "/" + file.Name()
|
|
content, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
logger.Error("Error reading file %s: %v", filePath, err)
|
|
continue
|
|
}
|
|
|
|
// Extract title from filename (remove .txt extension)
|
|
title := strings.TrimSuffix(file.Name(), ".txt")
|
|
|
|
// Select "Relaxation" as the default proclamation
|
|
isSelected := (title == "Relaxation")
|
|
|
|
proclamation := Proclamations{
|
|
Title: title,
|
|
Content: string(content),
|
|
Commercial: isCommercial,
|
|
Selected: isSelected,
|
|
Favorite: false,
|
|
Available: true,
|
|
}
|
|
|
|
if err := DB.Create(&proclamation).Error; err != nil {
|
|
logger.Error("Error creating proclamation from %s: %v", file.Name(), err)
|
|
return err
|
|
}
|
|
|
|
if isSelected {
|
|
logger.Error("Set default proclamation: %s", title)
|
|
}
|
|
|
|
totalSeeded++
|
|
}
|
|
}
|
|
|
|
logger.Error("Seeded %d default proclamations from %s", totalSeeded, proclamationsBaseDir)
|
|
return nil
|
|
}
|
|
|
|
// GetConfigValue retrieves a configuration value by key.
|
|
// Results are cached in memory to avoid repeated DB queries.
|
|
func GetConfigValue(key string) (string, error) {
|
|
configCache.RLock()
|
|
if v, ok := configCache.m[key]; ok {
|
|
configCache.RUnlock()
|
|
return v, nil
|
|
}
|
|
configCache.RUnlock()
|
|
|
|
var config Config
|
|
result := DB.First(&config, "key = ?", key)
|
|
if result.Error != nil {
|
|
return "", result.Error
|
|
}
|
|
|
|
configCache.Lock()
|
|
configCache.m[key] = config.Value
|
|
configCache.Unlock()
|
|
|
|
return config.Value, nil
|
|
}
|
|
|
|
// GetConfigValues retrieves all configuration key-value pairs
|
|
// Returns a map of key-value pairs OR any error encountered
|
|
// This is mainly for compatibility with legacy code, but can
|
|
// be useful for bulkl config referrals
|
|
func GetConfigValues() (map[string]string, error) {
|
|
var configs []Config
|
|
result := DB.Find(&configs)
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
configMap := make(map[string]string)
|
|
for _, config := range configs {
|
|
configMap[config.Key] = config.Value
|
|
}
|
|
return configMap, nil
|
|
}
|
|
|
|
// SetConfigValue sets a configuration value by key
|
|
// Returns any error encountered || nil
|
|
func SetConfigValue(key, value string) error {
|
|
// First try to find existing config
|
|
var config Config
|
|
result := DB.Where("key = ?", key).First(&config)
|
|
|
|
if result.Error != nil {
|
|
if result.Error == gorm.ErrRecordNotFound {
|
|
// Record doesn't exist, create new one
|
|
config = Config{Key: key, Value: value}
|
|
if err := DB.Create(&config).Error; err != nil {
|
|
return fmt.Errorf("failed to create config value for key %s: %w", key, err)
|
|
}
|
|
} else {
|
|
// Some other error occurred
|
|
return fmt.Errorf("failed to query config for key %s: %w", key, result.Error)
|
|
}
|
|
} else {
|
|
// Record exists, update it
|
|
config.Value = value
|
|
if err := DB.Save(&config).Error; err != nil {
|
|
return fmt.Errorf("failed to update config value for key %s: %w", key, err)
|
|
}
|
|
}
|
|
|
|
// Update cache
|
|
configCache.Lock()
|
|
configCache.m[key] = value
|
|
configCache.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetProclamations retrieves all proclamation entries from the database
|
|
// Returns a slice of Proclamations OR any error encountered
|
|
func GetProclamations() ([]Proclamations, error) {
|
|
var proclamations []Proclamations
|
|
result := DB.Find(&proclamations)
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
return proclamations, nil
|
|
}
|
|
|
|
// seedDefaultTOSSections reads HTML files from the assets directory and populates the
|
|
// TOS sections table on first run.
|
|
func seedDefaultTOSSections() error {
|
|
var count int64
|
|
if err := DB.Model(&TOSSection{}).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
|
|
assetsDir, err := GetConfigValue("assets_dir")
|
|
if err != nil {
|
|
return fmt.Errorf("getting assets_dir: %w", err)
|
|
}
|
|
|
|
tosDir := filepath.Join(assetsDir, "tos")
|
|
files, err := os.ReadDir(tosDir)
|
|
if err != nil {
|
|
return fmt.Errorf("reading tos directory %s: %w", tosDir, err)
|
|
}
|
|
|
|
seeded := 0
|
|
for _, file := range files {
|
|
if file.IsDir() || !strings.HasSuffix(file.Name(), ".html") {
|
|
continue
|
|
}
|
|
|
|
// Parse filename: "01 - WELCOME.html" -> number=1, title="WELCOME"
|
|
name := strings.TrimSuffix(file.Name(), ".html")
|
|
parts := strings.SplitN(name, " - ", 2)
|
|
if len(parts) != 2 {
|
|
logger.Error("Skipping TOS file with unexpected name format: %s", file.Name())
|
|
continue
|
|
}
|
|
|
|
var sectionNum int
|
|
if _, err := fmt.Sscanf(parts[0], "%d", §ionNum); err != nil {
|
|
logger.Error("Skipping TOS file, cannot parse section number: %s", file.Name())
|
|
continue
|
|
}
|
|
title := strings.TrimSpace(parts[1])
|
|
|
|
content, err := os.ReadFile(filepath.Join(tosDir, file.Name()))
|
|
if err != nil {
|
|
logger.Error("Error reading TOS file %s: %v", file.Name(), err)
|
|
continue
|
|
}
|
|
|
|
section := TOSSection{
|
|
SectionNumber: sectionNum,
|
|
Title: title,
|
|
Content: string(content),
|
|
}
|
|
if err := DB.Create(§ion).Error; err != nil {
|
|
return fmt.Errorf("creating TOS section from %s: %w", file.Name(), err)
|
|
}
|
|
seeded++
|
|
}
|
|
|
|
logger.Info("Seeded %d TOS sections from %s", seeded, tosDir)
|
|
return nil
|
|
}
|
|
|
|
// GetTOSSections returns all TOS sections ordered by section number.
|
|
func GetTOSSections() ([]TOSSection, error) {
|
|
var sections []TOSSection
|
|
result := DB.Order("section_number asc").Find(§ions)
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
return sections, nil
|
|
}
|
|
|
|
// GetLogs retrieves log entries from the database, ordered by creation time ascending.
|
|
// If limit > 0, at most that many entries are returned.
|
|
func GetLogs(limit int) ([]Log, error) {
|
|
var logs []Log
|
|
query := DB.Order("created_at DESC")
|
|
if limit > 0 {
|
|
query = query.Limit(limit)
|
|
}
|
|
return logs, query.Find(&logs).Error
|
|
}
|
|
|
|
// ensureDefaultsExist checks the current configs table rows and inserts any new rows
|
|
// from defaultConfigValues to the database if they don't exist.
|
|
// This is essentially an automatic migration for new config values.
|
|
func ensureDefaultsExist() error {
|
|
for key, value := range defaultConfigValues {
|
|
var config Config
|
|
result := DB.Where("key = ?", key).First(&config)
|
|
|
|
// Always re-resolve path-based config values from the current binary location
|
|
switch key {
|
|
case "assets_dir":
|
|
value = resolveAssetsDir()
|
|
case "log_dir":
|
|
value = filepath.Join(resolveAssetsDir(), "logs.log")
|
|
}
|
|
|
|
if result.Error == gorm.ErrRecordNotFound {
|
|
// Key doesn't exist, create it with default value
|
|
if err := DB.Create(&Config{Key: key, Value: value}).Error; err != nil {
|
|
return fmt.Errorf("failed to create default config for %s: %w", key, err)
|
|
}
|
|
log.Printf("Added missing config key: %s", key)
|
|
} else if result.Error != nil {
|
|
return result.Error
|
|
} else if (key == "assets_dir" || key == "log_dir") && config.Value != value {
|
|
// Update stale path-based values (e.g. after Nix store update)
|
|
config.Value = value
|
|
if err := DB.Save(&config).Error; err != nil {
|
|
return fmt.Errorf("failed to update config for %s: %w", key, err)
|
|
}
|
|
log.Printf("Updated stale config key: %s", key)
|
|
} else if key == "selected_audio" && config.Value == "audio/7.83.wav" {
|
|
// Migrate renamed audio file
|
|
config.Value = value
|
|
if err := DB.Save(&config).Error; err != nil {
|
|
return fmt.Errorf("failed to migrate config for %s: %w", key, err)
|
|
}
|
|
log.Printf("Migrated config key: %s", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func AudioEnabled() bool {
|
|
on, err := GetConfigValue("audio_enabled")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return on == "true"
|
|
}
|
|
|
|
// resolveAssetsDir returns the assets directory. If RHEMAWAVE_ASSETS_DIR is
|
|
// set, it is used directly (useful for debugging with dlv). Otherwise, it
|
|
// resolves relative to the running binary: .../bin/rhemawave → .../share/rhemawave.
|
|
// Falls back to /usr/local/share/rhemawave.
|
|
func resolveAssetsDir() string {
|
|
if dir := os.Getenv("RHEMAWAVE_ASSETS_DIR"); dir != "" {
|
|
return dir
|
|
}
|
|
exe, err := os.Executable()
|
|
if err == nil {
|
|
exe, err = filepath.EvalSymlinks(exe)
|
|
}
|
|
if err == nil {
|
|
return filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")
|
|
}
|
|
return filepath.Join("/usr", "local", "share", "rhemawave")
|
|
}
|