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 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") }