Files
radiantwave-legacy/internal/db/db.go
T

489 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", &sectionNum); 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(&section).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(&sections)
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 by resolving relative to the
// running binary. For a binary at .../bin/rhemawave, assets are at
// .../share/rhemawave. Falls back to /usr/local/share/rhemawave.
func resolveAssetsDir() string {
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")
}