Update Database

Database is more usable now.
All pages have been updated to use the database, and share an application configuration.
Page Stack has been removed, no more pages hanging around in memory. Replaced with a 'Router' which uses an Enum to determine which page should be displayed, destroys the old page and initializes the new page all in one go. No more push, pop, rollback, etc. No more holding instances of pages.
Logger has been updated with proper timestamps and sorting now works as expected."
This commit is contained in:
NeilClack
2026-03-23 15:02:07 -05:00
parent f8fbfb721d
commit 17c088cc2f
28 changed files with 537 additions and 2276 deletions
-492
View File
@@ -1,492 +0,0 @@
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. 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")
}
+48
View File
@@ -0,0 +1,48 @@
// Package db provides a database connection and functions for interacting with the database.
// The DB object should be held and maintained by the application, not by this package.
// The application should be responsible for connecting to, migrating, and initial population
// of the database. This package should only provide the schema definitions and helper functions.
// Schema definitions should clone API response structures as closely as possible.
package db
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Connect to the database and return the gorm.DB object.
func Connect(dbpath string) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dbpath), &gorm.Config{})
if err != nil {
return nil, err
}
// Set pragmas
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=FULL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
}
for _, pragma := range pragmas {
if _, err := sqlDB.Exec(pragma); err != nil {
return nil, err
}
}
return db, nil
}
// Migrate the database schema
func Migrate(db *gorm.DB) error {
// Pass all models to be migrated to this function call.
if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}); err != nil {
return err
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
// schema.go defines the database schema for the GORM models.
package db
import "gorm.io/gorm"
type AppConfig struct {
gorm.Model
DataDir string
AssetsDir string
EmailAddress string
LicenseKey string
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
SelectedAudio string // Audio filepath
SelectedAudioDevice string // The pipewire device name
InitialFontSize int // How large to create the font atlas
StandardFontSize int // The font size used for most text rendering
LinePattern string // "fibonacci"
LastVolume int // 0-128
AudioEnabled bool
}
type Log struct {
gorm.Model
Timestamp int64
Level string
Message string
}
type Proclamation struct {
gorm.Model
ID string `gorm:"primaryKey"`
Name string
Content string
SolfeggioFrequencyID int
CategoryID int
Selected bool
Available bool
Price float64
}