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:
@@ -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", §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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user