initial commit

This commit is contained in:
NeilClack
2026-08-07 14:51:41 -05:00
commit 9251b3a196
604 changed files with 464448 additions and 0 deletions
+953
View File
@@ -0,0 +1,953 @@
#include "database_manager.h"
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <pwd.h>
#include <unordered_map>
#include <unistd.h>
namespace radiantwave::db {
// ─── Constructor & Destructor ──────────────────────────────────────────
DatabaseManager::DatabaseManager() = default;
DatabaseManager::~DatabaseManager() {
close();
}
// ─── Path Resolution ───────────────────────────────────────────────────
std::string DatabaseManager::resolvePath(const std::string& raw_path) const {
std::string path = raw_path;
if (path.empty()) {
const char* home = std::getenv("HOME");
if (!home) {
struct passwd* pw = getpwuid(getuid());
if (pw) {
home = pw->pw_dir;
}
}
if (home) {
path = std::string(home) + "/.local/radiantwave/radiantwave.db";
} else {
path = "/tmp/radiantwave/radiantwave.db";
}
} else if (path.front() == '~') {
const char* home = std::getenv("HOME");
if (!home) {
struct passwd* pw = getpwuid(getuid());
if (pw) {
home = pw->pw_dir;
}
}
if (home) {
path = std::string(home) + path.substr(1);
}
}
return path;
}
// ─── Lifecycle & Migrations ────────────────────────────────────────────
bool DatabaseManager::initialize(const std::string& custom_path) {
db_path_ = resolvePath(custom_path);
// Create directories if needed
try {
std::filesystem::path p(db_path_);
if (p.has_parent_path()) {
std::filesystem::create_directories(p.parent_path());
}
} catch (const std::exception& e) {
std::cerr << "[DB] Failed to create directories: " << e.what() << std::endl;
return false;
}
int rc = sqlite3_open_v2(
db_path_.c_str(),
&db_,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX,
nullptr
);
if (rc != SQLITE_OK) {
std::cerr << "[DB] Cannot open database " << db_path_ << ": " << sqlite3_errmsg(db_) << std::endl;
return false;
}
// Enable WAL mode and foreign keys for optimal kiosk performance
executeSql("PRAGMA journal_mode=WAL;");
executeSql("PRAGMA foreign_keys=ON;");
return runMigrations();
}
void DatabaseManager::close() {
std::lock_guard<std::mutex> lock(mutex_);
if (db_) {
sqlite3_close_v2(db_);
db_ = nullptr;
}
}
bool DatabaseManager::isOpen() const {
return db_ != nullptr;
}
bool DatabaseManager::executeSql(const std::string& sql) {
char* err_msg = nullptr;
int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &err_msg);
if (rc != SQLITE_OK) {
std::cerr << "[DB] SQL error: " << (err_msg ? err_msg : "unknown") << std::endl;
if (err_msg) sqlite3_free(err_msg);
return false;
}
return true;
}
bool DatabaseManager::runMigrations() {
std::lock_guard<std::mutex> lock(mutex_);
const char* schema = R"(
CREATE TABLE IF NOT EXISTS system_kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS proclamations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
solfeggio_freq_hz REAL DEFAULT 528.0,
category TEXT DEFAULT 'general',
is_active INTEGER DEFAULT 1,
selected INTEGER DEFAULT 0,
category_id INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0,
required_level_id INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS proclamation_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS asset_downloads (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT CHECK (status IN ('pending', 'downloading', 'completed', 'failed')) DEFAULT 'pending',
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER DEFAULT 0,
error_message TEXT DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audio_files (
id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
filename TEXT NOT NULL UNIQUE,
display_order INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS event_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
level TEXT CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
component TEXT NOT NULL,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS color_palettes (
solfeggio_frequency_id INTEGER NOT NULL,
color_index INTEGER NOT NULL,
hex TEXT NOT NULL,
PRIMARY KEY (solfeggio_frequency_id, color_index)
);
)";
bool ok = executeSql(schema);
if (ok) {
// Back-fill columns for databases created before these fields existed.
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
ensureColumn("proclamations", "category_id", "category_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "required_level_id", "required_level_id INTEGER DEFAULT 1");
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
}
if (ok) {
seedColorPalettes();
}
return ok;
}
void DatabaseManager::seedColorPalettes() {
static const char* kSeedSql =
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
// 396 Hz
"(2,0,'#8B0000'),(2,1,'#B22222'),(2,2,'#DC143C'),(2,3,'#FF6347'),(2,4,'#FF8C69'),(2,5,'#FFA500'),(2,6,'#FFDAB9'),"
// 417 Hz
"(3,0,'#FF4500'),(3,1,'#FF7F50'),(3,2,'#FF8C00'),(3,3,'#FFA500'),(3,4,'#FFC87F'),(3,5,'#FFDFBA'),(3,6,'#FFEBCD'),"
// 528 Hz
"(5,0,'#32CD32'),(5,1,'#3CB371'),(5,2,'#90EE90'),(5,3,'#98FB98'),(5,4,'#ADFFA9'),(5,5,'#C8FFD2'),(5,6,'#F0FFF0'),"
// 639 Hz
"(6,0,'#00FF7F'),(6,1,'#00CED1'),(6,2,'#40E0D0'),(6,3,'#5F9EA0'),(6,4,'#7FFFD4'),(6,5,'#AFEEEE'),(6,6,'#E0FFFF'),"
// 741 Hz
"(7,0,'#00BFFF'),(7,1,'#1E90FF'),(7,2,'#4169E1'),(7,3,'#6495ED'),(7,4,'#87CEEB'),(7,5,'#ADD8E6'),(7,6,'#F0F8FF'),"
// 852 Hz
"(8,0,'#4B0082'),(8,1,'#9370DB'),(8,2,'#8A2BE2'),(8,3,'#7B68EE'),(8,4,'#DA70D6'),(8,5,'#D8BFD8'),(8,6,'#E6E6FA'),"
// 963 Hz
"(9,0,'#9400D3'),(9,1,'#BA55D3'),(9,2,'#C71585'),(9,3,'#DDA0DD'),(9,4,'#EE82EE'),(9,5,'#FFF0F5'),(9,6,'#FAF0FA'),"
// 174/285 Hz (grounding/relief): warm gold
"(1,0,'#8B6914'),(1,1,'#B8860B'),(1,2,'#DAA520'),(1,3,'#E8B64C'),(1,4,'#FFD700'),(1,5,'#FFE9A8'),(1,6,'#FFF4CC'),"
// 432 Hz (clarity/wellbeing): soft rose
"(4,0,'#8B2252'),(4,1,'#CD3278'),(4,2,'#DB7093'),(4,3,'#FF69B4'),(4,4,'#FFB6C1'),(4,5,'#FFC0CB'),(4,6,'#FFE4E1');";
executeSql(kSeedSql);
}
bool DatabaseManager::columnExists(const std::string& table, const std::string& column) {
if (!db_) return false;
const char* sql = "SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, table.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, column.c_str(), -1, SQLITE_TRANSIENT);
bool exists = (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_int(stmt, 0) > 0);
sqlite3_finalize(stmt);
return exists;
}
bool DatabaseManager::ensureColumn(const std::string& table, const std::string& column,
const std::string& column_ddl) {
if (columnExists(table, column)) {
return true;
}
return executeSql("ALTER TABLE " + table + " ADD COLUMN " + column_ddl + ";");
}
// ─── Key-Value Storage (system_kv table) ───────────────────────────────
bool DatabaseManager::setKV(const std::string& key, const std::string& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "INSERT INTO system_kv (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=CURRENT_TIMESTAMP;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
std::string DatabaseManager::getKV(const std::string& key, const std::string& defaultValue) const {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return defaultValue;
const char* sql = "SELECT value FROM system_kv WHERE key = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return defaultValue;
}
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
std::string result = defaultValue;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* text = sqlite3_column_text(stmt, 0);
if (text) {
result = reinterpret_cast<const char*>(text);
}
}
sqlite3_finalize(stmt);
return result;
}
bool DatabaseManager::hasKV(const std::string& key) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "SELECT 1 FROM system_kv WHERE key = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
bool exists = (sqlite3_step(stmt) == SQLITE_ROW);
sqlite3_finalize(stmt);
return exists;
}
bool DatabaseManager::deleteKV(const std::string& key) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "DELETE FROM system_kv WHERE key = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
// ─── Proclamations ─────────────────────────────────────────────────────
void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const ProclamationRecord& record) {
sqlite3_bind_text(stmt, 1, record.id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, record.title.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, record.content.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_double(stmt, 4, record.solfeggio_freq_hz);
sqlite3_bind_text(stmt, 5, record.category.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
sqlite3_bind_int(stmt, 8, record.category_id);
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
sqlite3_bind_int(stmt, 10, record.required_level_id);
}
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id, required_level_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, selected=excluded.selected, "
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id, "
"required_level_id=excluded.required_level_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
bindProclamationRecord(stmt, record);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCol) {
ProclamationRecord rec;
rec.id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, firstCol + 0));
rec.title = reinterpret_cast<const char*>(sqlite3_column_text(stmt, firstCol + 1));
rec.content = reinterpret_cast<const char*>(sqlite3_column_text(stmt, firstCol + 2));
rec.solfeggio_freq_hz = sqlite3_column_double(stmt, firstCol + 3);
rec.category = reinterpret_cast<const char*>(sqlite3_column_text(stmt, firstCol + 4));
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
rec.required_level_id = sqlite3_column_int(stmt, firstCol + 9);
return rec;
}
std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProclamationRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id, required_level_id "
"FROM proclamations WHERE is_active = 1;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
result.push_back(readProclamationRecord(stmt, 0));
}
sqlite3_finalize(stmt);
return result;
}
bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
const char* delSql = "DELETE FROM proclamation_categories;";
if (sqlite3_exec(db_, delSql, nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
"VALUES (?, ?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
for (const auto& cat : records) {
sqlite3_bind_int(stmt, 1, cat.id);
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, cat.parent_id);
sqlite3_bind_int(stmt, 4, cat.sort_order);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
}
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
}
sqlite3_finalize(stmt);
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& records,
const std::string& defaultProclamationId) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
// Stash current selection so a re-sync doesn't reset what the user picked.
std::unordered_map<std::string, bool> stash;
{
const char* selSql = "SELECT id, selected FROM proclamations;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, selSql, -1, &stmt, nullptr) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
std::string id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
stash[id] = (sqlite3_column_int(stmt, 1) != 0);
}
sqlite3_finalize(stmt);
}
}
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
// Remove proclamations the API no longer returns, but never the default one.
{
std::string delSql = "DELETE FROM proclamations WHERE id NOT IN (";
if (!records.empty()) {
for (size_t i = 0; i < records.size(); ++i) {
delSql += i == 0 ? "?" : ",?";
}
} else {
delSql += "''";
}
delSql += ") AND id != ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, delSql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
ok = false;
} else {
for (size_t i = 0; i < records.size(); ++i) {
sqlite3_bind_text(stmt, static_cast<int>(i + 1), records[i].id.c_str(), -1, SQLITE_TRANSIENT);
}
sqlite3_bind_text(stmt, static_cast<int>(records.size() + 1), defaultProclamationId.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
}
sqlite3_finalize(stmt);
}
}
// Upsert each proclamation, preserving the stashed selection state.
if (ok) {
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
"is_active, selected, category_id, solfeggio_frequency_id, required_level_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
"is_active=excluded.is_active, category_id=excluded.category_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id, "
"required_level_id=excluded.required_level_id;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
ok = false;
} else {
for (const auto& rec : records) {
ProclamationRecord r = rec;
auto it = stash.find(r.id);
r.selected = (it != stash.end()) ? it->second : false;
bindProclamationRecord(stmt, r);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
}
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
}
sqlite3_finalize(stmt);
}
}
// Force the default proclamation to remain selected.
if (ok) {
const char* defSql = "UPDATE proclamations SET selected = 1 WHERE id = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, defSql, -1, &stmt, nullptr) == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, defaultProclamationId.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
}
sqlite3_finalize(stmt);
} else {
ok = false;
}
}
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProclamationRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
"selected, category_id, solfeggio_frequency_id, required_level_id FROM proclamations;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
result.push_back(readProclamationRecord(stmt, 0));
}
sqlite3_finalize(stmt);
return result;
}
std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProclamationCategoryRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, name, parent_id, sort_order FROM proclamation_categories "
"ORDER BY sort_order ASC, id ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
ProclamationCategoryRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.parent_id = sqlite3_column_int(stmt, 2);
rec.sort_order = sqlite3_column_int(stmt, 3);
result.push_back(rec);
}
sqlite3_finalize(stmt);
return result;
}
bool DatabaseManager::setProclamationSelected(const std::string& id, bool selected) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "UPDATE proclamations SET selected = ? WHERE id = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_int(stmt, 1, selected ? 1 : 0);
sqlite3_bind_text(stmt, 2, id.c_str(), -1, SQLITE_TRANSIENT);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
bool DatabaseManager::saveSelectedProclamations(const std::vector<std::string>& selectedIds,
const std::string& defaultProclamationId) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_exec(db_, "UPDATE proclamations SET selected = 0;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* sql = "UPDATE proclamations SET selected = 1 WHERE id = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
for (const auto& id : selectedIds) {
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
}
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
}
sqlite3_finalize(stmt);
if (ok) {
ok = ensureDefaultProclamationSelectedUnlocked(defaultProclamationId);
}
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
bool DatabaseManager::ensureDefaultProclamationSelected(const std::string& defaultProclamationId) {
std::lock_guard<std::mutex> lock(mutex_);
return ensureDefaultProclamationSelectedUnlocked(defaultProclamationId);
}
bool DatabaseManager::ensureDefaultProclamationSelectedUnlocked(const std::string& defaultProclamationId) {
if (!db_) return false;
const char* sql = "UPDATE proclamations SET selected = 1 WHERE id = ?;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, defaultProclamationId.c_str(), -1, SQLITE_TRANSIENT);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
// ─── Asset Downloads ───────────────────────────────────────────────────
bool DatabaseManager::updateAssetDownload(const AssetDownloadRecord& record) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "INSERT INTO asset_downloads (id, filename, url, status, bytes_downloaded, total_bytes, error_message, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(id) DO UPDATE SET filename=excluded.filename, url=excluded.url, status=excluded.status, "
"bytes_downloaded=excluded.bytes_downloaded, total_bytes=excluded.total_bytes, error_message=excluded.error_message, "
"updated_at=CURRENT_TIMESTAMP;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, record.id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, record.filename.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, record.url.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, record.status.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 5, record.bytes_downloaded);
sqlite3_bind_int64(stmt, 6, record.total_bytes);
sqlite3_bind_text(stmt, 7, record.error_message.c_str(), -1, SQLITE_TRANSIENT);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
std::vector<AssetDownloadRecord> DatabaseManager::getPendingAssetDownloads() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<AssetDownloadRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, filename, url, status, bytes_downloaded, total_bytes, error_message FROM asset_downloads WHERE status = 'pending';";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
AssetDownloadRecord rec;
rec.id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
rec.filename = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.url = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
rec.status = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
rec.bytes_downloaded = sqlite3_column_int64(stmt, 4);
rec.total_bytes = sqlite3_column_int64(stmt, 5);
rec.error_message = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 6));
result.push_back(rec);
}
sqlite3_finalize(stmt);
return result;
}
// ─── Event Logging ─────────────────────────────────────────────────────
bool DatabaseManager::logEvent(const std::string& level, const std::string& component, const std::string& message) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
const char* sql = "INSERT INTO event_logs (level, component, message) VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, level.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, component.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, message.c_str(), -1, SQLITE_TRANSIENT);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return rc == SQLITE_DONE;
}
// ─── Color Palettes Table ──────────────────────────────────────────────
static std::vector<PaletteColor> readPaletteUnlocked(sqlite3* db, int solfeggio_frequency_id) {
std::vector<PaletteColor> result;
if (!db) return result;
const char* sql = "SELECT hex FROM color_palettes WHERE solfeggio_frequency_id = ? "
"ORDER BY color_index ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
sqlite3_bind_int(stmt, 1, solfeggio_frequency_id);
auto parseHex = [](const std::string& hex) -> PaletteColor {
PaletteColor c;
auto nibble = [&hex](size_t i) -> int {
char ch = hex.size() > i ? hex[i] : '0';
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return 0;
};
// Accept "#RRGGBB" or "RRGGBB".
size_t base = (!hex.empty() && hex[0] == '#') ? 1 : 0;
c.r = static_cast<uint8_t>(nibble(base) * 16 + nibble(base + 1));
c.g = static_cast<uint8_t>(nibble(base + 2) * 16 + nibble(base + 3));
c.b = static_cast<uint8_t>(nibble(base + 4) * 16 + nibble(base + 5));
return c;
};
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* text = sqlite3_column_text(stmt, 0);
if (text) {
result.push_back(parseHex(reinterpret_cast<const char*>(text)));
}
}
sqlite3_finalize(stmt);
return result;
}
std::vector<PaletteColor> DatabaseManager::getPaletteForFrequencyId(int solfeggio_frequency_id) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return {};
std::vector<PaletteColor> result = readPaletteUnlocked(db_, solfeggio_frequency_id);
if (result.empty()) {
result = readPaletteUnlocked(db_, 5); // 528 Hz fallback
}
return result;
}
bool DatabaseManager::replaceColorPalettes(const std::vector<PaletteRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
if (sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_exec(db_, "DELETE FROM color_palettes;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* insSql = "INSERT INTO color_palettes (solfeggio_frequency_id, color_index, hex) "
"VALUES (?, ?, ?);";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
for (const auto& rec : records) {
for (size_t i = 0; i < rec.colors.size(); ++i) {
const PaletteColor& c = rec.colors[i];
char hex[8];
std::snprintf(hex, sizeof(hex), "#%02x%02x%02x", c.r, c.g, c.b);
sqlite3_bind_int(stmt, 1, rec.solfeggio_frequency_id);
sqlite3_bind_int(stmt, 2, static_cast<int>(i));
sqlite3_bind_text(stmt, 3, hex, -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) != SQLITE_DONE) {
ok = false;
break;
}
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
}
if (!ok) break;
}
sqlite3_finalize(stmt);
if (ok) {
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
} else {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
}
return ok;
}
// ─── Audio Files Table ─────────────────────────────────────────────────
bool DatabaseManager::syncAudioFiles(const std::vector<AudioFileRecord>& records) {
std::lock_guard<std::mutex> lock(mutex_);
if (!db_) return false;
sqlite3_exec(db_, "BEGIN;", nullptr, nullptr, nullptr);
bool ok = true;
// Upsert each record by filename. The manifest is the source of truth for
// display_name / display_order / solfeggio_frequency_id, so all are
// refreshed on every sync.
const char* upsertSql =
"INSERT INTO audio_files (display_name, filename, display_order, solfeggio_frequency_id) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(filename) DO UPDATE SET "
"display_name=excluded.display_name, display_order=excluded.display_order, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
for (const auto& rec : records) {
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, upsertSql, -1, &stmt, nullptr) != SQLITE_OK) {
ok = false;
continue;
}
sqlite3_bind_text(stmt, 1, rec.display_name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, rec.filename.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, rec.display_order);
sqlite3_bind_int(stmt, 4, rec.solfeggio_frequency_id);
if (sqlite3_step(stmt) != SQLITE_DONE) ok = false;
sqlite3_finalize(stmt);
}
// Delete rows whose filename is not part of the provided set.
sqlite3_stmt* delStmt = nullptr;
if (records.empty()) {
ok = ok && executeSql("DELETE FROM audio_files;");
} else {
std::string deleteSql = "DELETE FROM audio_files WHERE filename NOT IN (";
for (size_t i = 0; i < records.size(); ++i) {
if (i > 0) deleteSql += ", ";
deleteSql += "?";
}
deleteSql += ");";
if (sqlite3_prepare_v2(db_, deleteSql.c_str(), -1, &delStmt, nullptr) == SQLITE_OK) {
for (size_t i = 0; i < records.size(); ++i) {
sqlite3_bind_text(delStmt, static_cast<int>(i + 1), records[i].filename.c_str(), -1, SQLITE_TRANSIENT);
}
if (sqlite3_step(delStmt) != SQLITE_DONE) ok = false;
sqlite3_finalize(delStmt);
} else {
ok = false;
}
}
sqlite3_exec(db_, "COMMIT;", nullptr, nullptr, nullptr);
return ok;
}
std::vector<AudioFileRecord> DatabaseManager::getAudioFiles() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<AudioFileRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, display_name, filename, display_order, solfeggio_frequency_id "
"FROM audio_files ORDER BY display_order ASC;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
return result;
}
while (sqlite3_step(stmt) == SQLITE_ROW) {
AudioFileRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.display_name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.filename = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
rec.display_order = sqlite3_column_int(stmt, 3);
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, 4);
result.push_back(rec);
}
sqlite3_finalize(stmt);
return result;
}
} // namespace radiantwave::db
+161
View File
@@ -0,0 +1,161 @@
#pragma once
#include <sqlite3.h>
#include <string>
#include <vector>
#include <mutex>
namespace radiantwave::db {
struct ProclamationRecord {
std::string id;
std::string title;
std::string content;
double solfeggio_freq_hz = 528.0;
std::string category = "general";
bool is_active = true;
bool selected = false;
int category_id = 0; // maps to ProclamationCategoryRecord::id
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
int required_level_id = 1; // minimum SubscriptionLevel id to unlock it
};
struct ProclamationCategoryRecord {
int id = 0;
std::string name;
int parent_id = 0; // 0 = root category
int sort_order = 0;
};
struct AssetDownloadRecord {
std::string id;
std::string filename;
std::string url;
std::string status = "pending"; // pending, downloading, completed, failed
int64_t bytes_downloaded = 0;
int64_t total_bytes = 0;
std::string error_message = "";
};
struct AudioFileRecord {
int id = 0;
std::string display_name;
std::string filename;
int display_order = 0;
int solfeggio_frequency_id = 0; // 0 = not associated with a solfeggio frequency
};
// A single color in a solfeggio frequency's color palette (RGBA).
struct PaletteColor {
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 255;
};
// The ordered palette for one solfeggio frequency (as stored in the
// color_palettes table, keyed by color_index).
struct PaletteRecord {
int solfeggio_frequency_id = 0;
std::vector<PaletteColor> colors;
};
class DatabaseManager {
public:
DatabaseManager();
~DatabaseManager();
// Non-copyable
DatabaseManager(const DatabaseManager&) = delete;
DatabaseManager& operator=(const DatabaseManager&) = delete;
// Initialize & open database at path (default: ~/.local/radiantwave/radiantwave.db)
bool initialize(const std::string& custom_path = "");
// Close database connection
void close();
// True once initialize() has opened the database.
bool isOpen() const;
// ── Key-Value Storage (system_kv table) ───────────────────────────
bool setKV(const std::string& key, const std::string& value);
std::string getKV(const std::string& key, const std::string& defaultValue = "") const;
bool hasKV(const std::string& key);
bool deleteKV(const std::string& key);
// ── Proclamations Table ───────────────────────────────────────────
bool saveProclamation(const ProclamationRecord& record);
std::vector<ProclamationRecord> getActiveProclamations();
// Full sync: replaces all categories with the given set.
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
// Full sync: upserts proclamations, deletes any not in `records`,
// preserves the existing `selected` state, and forces the proclamation
// identified by `defaultProclamationId` to remain selected.
bool syncProclamations(const std::vector<ProclamationRecord>& records,
const std::string& defaultProclamationId);
// Fetch all proclamations (regardless of is_active).
std::vector<ProclamationRecord> getAllProclamations();
// Fetch all categories ordered by sort_order then id.
std::vector<ProclamationCategoryRecord> getAllCategories();
// Toggle a single proclamation's selected flag.
bool setProclamationSelected(const std::string& id, bool selected);
// Atomically replace the selection with `selectedIds`, then force the
// default proclamation to remain selected.
bool saveSelectedProclamations(const std::vector<std::string>& selectedIds,
const std::string& defaultProclamationId);
// Force the default proclamation to be flagged selected in the database.
bool ensureDefaultProclamationSelected(const std::string& defaultProclamationId);
// ── Asset Downloads Table ─────────────────────────────────────────
bool updateAssetDownload(const AssetDownloadRecord& record);
std::vector<AssetDownloadRecord> getPendingAssetDownloads();
// ── Audio Files Table ─────────────────────────────────────────────
// Reconciles audio_files to the given set (the on-disk audio files).
// Upserts by filename with the manifest as source of truth for
// display_name / display_order / solfeggio_frequency_id, and deletes
// any row whose filename is not in `records`.
bool syncAudioFiles(const std::vector<AudioFileRecord>& records);
std::vector<AudioFileRecord> getAudioFiles();
// ── Color Palettes Table ──────────────────────────────────────────
// Returns the 7-color palette for a solfeggio frequency id, ordered by
// color_index. Falls back to the 528 Hz palette when the id has no rows.
std::vector<PaletteColor> getPaletteForFrequencyId(int solfeggio_frequency_id);
// Full sync: replaces the entire color_palettes table with `records`
// (the portal is the source of truth for palettes). Pass an empty vector
// to clear all palettes. Colors are stored as "#RRGGBB" hex strings.
//
// NOTE: not currently called from the app flow. This is readiness code so
// palettes can be pushed from the portal once the API route exists.
bool replaceColorPalettes(const std::vector<PaletteRecord>& records);
// ── Event Logging Table ───────────────────────────────────────────
bool logEvent(const std::string& level, const std::string& component, const std::string& message);
private:
std::string resolvePath(const std::string& raw_path) const;
bool runMigrations();
bool executeSql(const std::string& sql);
bool columnExists(const std::string& table, const std::string& column);
bool ensureColumn(const std::string& table, const std::string& column,
const std::string& column_ddl);
void seedColorPalettes();
void bindProclamationRecord(sqlite3_stmt* stmt, const ProclamationRecord& record);
bool ensureDefaultProclamationSelectedUnlocked(const std::string& defaultProclamationId);
sqlite3* db_ = nullptr;
std::string db_path_;
mutable std::mutex mutex_;
};
} // namespace radiantwave::db
+18
View File
@@ -0,0 +1,18 @@
#pragma once
namespace radiantwave::db {
// Canonical system_kv keys. These are the single source of truth for where
// device configuration lives in the local database.
constexpr const char* KV_EMAIL_ADDRESS = "email_address";
constexpr const char* KV_LICENSE_KEY = "license_key";
constexpr const char* KV_SUBSCRIPTION_LEVEL_ID = "subscription_level_id";
constexpr const char* KV_SUBSCRIPTION_LEVEL_NAME = "subscription_level_name";
constexpr const char* KV_SUBSCRIPTION_LEVEL_DESCRIPTION = "subscription_level_description";
// Unix epoch seconds of the last successful license validation against the
// portal. Used to grant a 7-day offline grace window so transient network
// outages do not revoke a known-good license.
constexpr const char* KV_LICENSE_LAST_VERIFIED = "license_last_verified";
} // namespace radiantwave::db