build-dev / nix-build (push) Successful in 32s
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.
1087 lines
40 KiB
C++
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
|