Files
radiantwave/src/db/database_manager.cpp
T
NeilClack 9fead922c4
build-dev / nix-build (push) Successful in 32s
Move Proclamations From Sub-Categories To Groups
The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.

Database:
- Add the proclamation_groups table (id, name, sort_order) and a
  proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
  flat table.
- runMigrations() creates the new table/column, then runs a one-time
  transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
  sub-category becomes a group (its id is preserved), proclamations that
  pointed at a sub-category are remapped to (parent category, new group), and
  the now-stale sub-category rows are deleted. selected flags are never
  touched, so user choices survive. No network is needed, so offline kiosks
  migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.

API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
  leaves the database untouched if any request or parse fails, then replaces
  categories, replaces groups and upserts proclamations.

Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
  category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
  directly under the category. Groups are collapsible and use a distinct color.

Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
  group is created, proclamations are remapped, selection is preserved and a
  second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
  the live smoke test now also fetches /groups.

Docs: README database table and AGENTS notes updated for groups.
2026-09-10 19:49:47 -05:00

1087 lines
40 KiB
C++

#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,
group_id INTEGER DEFAULT 0,
solfeggio_frequency_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS proclamation_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS proclamation_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
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", "group_id", "group_id INTEGER DEFAULT 0");
ensureColumn("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
ensureColumn("audio_files", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
// Clean up the pre-active/inactive-license subscription level keys.
// Existing devices may still carry them; nothing reads them anymore.
executeSql("DELETE FROM system_kv WHERE key IN "
"('subscription_level_id','subscription_level_name','subscription_level_description');");
}
if (ok) {
ok = migrateSubCategoriesToGroups();
}
if (ok) {
seedColorPalettes();
}
return ok;
}
// Schema migration v2: sub-categories -> tag-like groups.
//
// Pre-v2 databases modelled a two-level category tree via
// `proclamation_categories.parent_id`, and proclamations pointed at either a
// root or a sub-category. v2 flattens categories and turns each former
// sub-category into a group, remapping the proclamations that referenced it to
// its parent category + the new group. Selection flags are never touched, so
// the user's picks survive the upgrade. `PRAGMA user_version` records the
// migration so it runs exactly once; the whole thing is one transaction so a
// failure leaves the database untouched. No network is required, so this
// always runs at startup, including on offline kiosks.
bool DatabaseManager::migrateSubCategoriesToGroups() {
if (!db_) return false;
int userVersion = 0;
{
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_, "PRAGMA user_version;", -1, &stmt, nullptr) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
userVersion = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
}
if (userVersion >= 2) {
return true; // already migrated
}
// Fresh databases (created by the v2 schema) have no legacy parent_id
// column, so there is nothing to convert: just stamp the version.
if (!columnExists("proclamation_categories", "parent_id")) {
return executeSql("PRAGMA user_version = 2;");
}
bool migrated = executeSql(
"BEGIN;"
"INSERT OR IGNORE INTO proclamation_groups (id, name, sort_order) "
" SELECT id, name, sort_order FROM proclamation_categories WHERE parent_id != 0;"
"UPDATE proclamations "
" SET group_id = category_id, "
" category_id = COALESCE("
" (SELECT parent_id FROM proclamation_categories "
" WHERE id = proclamations.category_id), "
" category_id) "
" WHERE category_id IN "
" (SELECT id FROM proclamation_categories WHERE parent_id != 0);"
"DELETE FROM proclamation_categories WHERE parent_id != 0;"
"PRAGMA user_version = 2;"
"COMMIT;");
if (!migrated) {
executeSql("ROLLBACK;");
}
return migrated;
}
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.group_id);
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_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, group_id, solfeggio_frequency_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, group_id=excluded.group_id, "
"solfeggio_frequency_id=excluded.solfeggio_frequency_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.group_id = sqlite3_column_int(stmt, firstCol + 8);
rec.solfeggio_frequency_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, group_id, solfeggio_frequency_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, 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.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::replaceGroups(const std::vector<ProclamationGroupRecord>& 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 proclamation_groups;", nullptr, nullptr, nullptr) != SQLITE_OK) {
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
return false;
}
const char* insSql = "INSERT INTO proclamation_groups (id, name, 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& group : records) {
sqlite3_bind_int(stmt, 1, group.id);
sqlite3_bind_text(stmt, 2, group.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 3, group.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, group_id, solfeggio_frequency_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, "
"group_id=excluded.group_id, solfeggio_frequency_id=excluded.solfeggio_frequency_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, group_id, solfeggio_frequency_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, 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.sort_order = sqlite3_column_int(stmt, 2);
result.push_back(rec);
}
sqlite3_finalize(stmt);
return result;
}
std::vector<ProclamationGroupRecord> DatabaseManager::getAllGroups() {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProclamationGroupRecord> result;
if (!db_) return result;
const char* sql = "SELECT id, name, sort_order FROM proclamation_groups "
"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) {
ProclamationGroupRecord rec;
rec.id = sqlite3_column_int(stmt, 0);
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
rec.sort_order = sqlite3_column_int(stmt, 2);
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