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.
This commit is contained in:
@@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass.
|
|||||||
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
||||||
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
||||||
from it.
|
from it.
|
||||||
|
- Proclamations carry a flat `category_id` plus a tag-like `group_id`
|
||||||
|
(`proclamation_groups`). The Options page lists every category and, under it,
|
||||||
|
only the groups that have at least one member in that category; ungrouped
|
||||||
|
proclamations sit directly under the category. `runMigrations()` performs a
|
||||||
|
one-time v1→v2 migration (`PRAGMA user_version`) that converts legacy
|
||||||
|
`proclamation_categories.parent_id` sub-categories into groups and remaps
|
||||||
|
their proclamations, preserving selections. No network is needed for it, so
|
||||||
|
offline kiosks migrate on startup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
|
|||||||
| ------- | --------- |
|
| ------- | --------- |
|
||||||
| app_config | Key-value store: license, email, audio device, volume |
|
| app_config | Key-value store: license, email, audio device, volume |
|
||||||
| logs | Application logs |
|
| logs | Application logs |
|
||||||
| proclamations | Synced proclamation text |
|
| proclamations | Synced proclamation text (category_id + group_id) |
|
||||||
| proclamation_categories | Category metadata |
|
| proclamation_categories | Flat category metadata |
|
||||||
|
| proclamation_groups | Tag-like groups that organize proclamations within a category |
|
||||||
| solfeggio_frequencies | 9 frequency presets |
|
| solfeggio_frequencies | 9 frequency presets |
|
||||||
| audio_files | Downloaded audio files |
|
| audio_files | Downloaded audio files |
|
||||||
|
|
||||||
|
|||||||
+153
-18
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
|
|||||||
is_active INTEGER DEFAULT 1,
|
is_active INTEGER DEFAULT 1,
|
||||||
selected INTEGER DEFAULT 0,
|
selected INTEGER DEFAULT 0,
|
||||||
category_id INTEGER DEFAULT 0,
|
category_id INTEGER DEFAULT 0,
|
||||||
|
group_id INTEGER DEFAULT 0,
|
||||||
solfeggio_frequency_id INTEGER DEFAULT 0,
|
solfeggio_frequency_id INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
|
|||||||
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
parent_id INTEGER DEFAULT 0,
|
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
|
sort_order INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -182,6 +188,7 @@ bool DatabaseManager::runMigrations() {
|
|||||||
// Back-fill columns for databases created before these fields existed.
|
// Back-fill columns for databases created before these fields existed.
|
||||||
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
|
ensureColumn("proclamations", "selected", "selected INTEGER DEFAULT 0");
|
||||||
ensureColumn("proclamations", "category_id", "category_id 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("proclamations", "solfeggio_frequency_id", "solfeggio_frequency_id INTEGER DEFAULT 0");
|
||||||
ensureColumn("audio_files", "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.
|
// Clean up the pre-active/inactive-license subscription level keys.
|
||||||
@@ -189,12 +196,72 @@ bool DatabaseManager::runMigrations() {
|
|||||||
executeSql("DELETE FROM system_kv WHERE key IN "
|
executeSql("DELETE FROM system_kv WHERE key IN "
|
||||||
"('subscription_level_id','subscription_level_name','subscription_level_description');");
|
"('subscription_level_id','subscription_level_name','subscription_level_description');");
|
||||||
}
|
}
|
||||||
|
if (ok) {
|
||||||
|
ok = migrateSubCategoriesToGroups();
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
seedColorPalettes();
|
seedColorPalettes();
|
||||||
}
|
}
|
||||||
return ok;
|
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() {
|
void DatabaseManager::seedColorPalettes() {
|
||||||
static const char* kSeedSql =
|
static const char* kSeedSql =
|
||||||
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
|
"INSERT OR IGNORE INTO color_palettes (solfeggio_frequency_id, color_index, hex) VALUES "
|
||||||
@@ -337,7 +404,8 @@ void DatabaseManager::bindProclamationRecord(sqlite3_stmt* stmt, const Proclamat
|
|||||||
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
|
sqlite3_bind_int(stmt, 6, record.is_active ? 1 : 0);
|
||||||
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
|
sqlite3_bind_int(stmt, 7, record.selected ? 1 : 0);
|
||||||
sqlite3_bind_int(stmt, 8, record.category_id);
|
sqlite3_bind_int(stmt, 8, record.category_id);
|
||||||
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
|
sqlite3_bind_int(stmt, 9, record.group_id);
|
||||||
|
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||||
@@ -345,12 +413,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
|||||||
if (!db_) return false;
|
if (!db_) return false;
|
||||||
|
|
||||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||||
"is_active=excluded.is_active, selected=excluded.selected, "
|
"is_active=excluded.is_active, selected=excluded.selected, "
|
||||||
"category_id=excluded.category_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
"category_id=excluded.category_id, group_id=excluded.group_id, "
|
||||||
|
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||||
|
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -374,7 +443,8 @@ static ProclamationRecord readProclamationRecord(sqlite3_stmt* stmt, int firstCo
|
|||||||
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
|
rec.is_active = (sqlite3_column_int(stmt, firstCol + 5) != 0);
|
||||||
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
rec.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
||||||
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
|
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
|
||||||
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
|
rec.group_id = sqlite3_column_int(stmt, firstCol + 8);
|
||||||
|
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 9);
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +454,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
|
|||||||
if (!db_) return result;
|
if (!db_) return result;
|
||||||
|
|
||||||
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
||||||
"selected, category_id, solfeggio_frequency_id "
|
"selected, category_id, group_id, solfeggio_frequency_id "
|
||||||
"FROM proclamations WHERE is_active = 1;";
|
"FROM proclamations WHERE is_active = 1;";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -414,8 +484,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
|
const char* insSql = "INSERT INTO proclamation_categories (id, name, sort_order) "
|
||||||
"VALUES (?, ?, ?, ?);";
|
"VALUES (?, ?, ?);";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, insSql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
sqlite3_exec(db_, "ROLLBACK;", nullptr, nullptr, nullptr);
|
||||||
@@ -425,8 +495,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
|||||||
for (const auto& cat : records) {
|
for (const auto& cat : records) {
|
||||||
sqlite3_bind_int(stmt, 1, cat.id);
|
sqlite3_bind_int(stmt, 1, cat.id);
|
||||||
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_bind_int(stmt, 3, cat.parent_id);
|
sqlite3_bind_int(stmt, 3, cat.sort_order);
|
||||||
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::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) {
|
if (sqlite3_step(stmt) != SQLITE_DONE) {
|
||||||
ok = false;
|
ok = false;
|
||||||
break;
|
break;
|
||||||
@@ -499,12 +611,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
|
|||||||
// Upsert each proclamation, preserving the stashed selection state.
|
// Upsert each proclamation, preserving the stashed selection state.
|
||||||
if (ok) {
|
if (ok) {
|
||||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
"is_active, selected, category_id, group_id, solfeggio_frequency_id) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, content=excluded.content, "
|
||||||
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
"solfeggio_freq_hz=excluded.solfeggio_freq_hz, category=excluded.category, "
|
||||||
"is_active=excluded.is_active, category_id=excluded.category_id, "
|
"is_active=excluded.is_active, category_id=excluded.category_id, "
|
||||||
"solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
"group_id=excluded.group_id, solfeggio_frequency_id=excluded.solfeggio_frequency_id;";
|
||||||
|
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -555,7 +667,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
|
|||||||
if (!db_) return result;
|
if (!db_) return result;
|
||||||
|
|
||||||
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
const char* sql = "SELECT id, title, content, solfeggio_freq_hz, category, is_active, "
|
||||||
"selected, category_id, solfeggio_frequency_id FROM proclamations;";
|
"selected, category_id, group_id, solfeggio_frequency_id FROM proclamations;";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
return result;
|
return result;
|
||||||
@@ -574,7 +686,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
|||||||
std::vector<ProclamationCategoryRecord> result;
|
std::vector<ProclamationCategoryRecord> result;
|
||||||
if (!db_) return result;
|
if (!db_) return result;
|
||||||
|
|
||||||
const char* sql = "SELECT id, name, parent_id, sort_order FROM proclamation_categories "
|
const char* sql = "SELECT id, name, sort_order FROM proclamation_categories "
|
||||||
"ORDER BY sort_order ASC, id ASC;";
|
"ORDER BY sort_order ASC, id ASC;";
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
@@ -585,8 +697,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
|||||||
ProclamationCategoryRecord rec;
|
ProclamationCategoryRecord rec;
|
||||||
rec.id = sqlite3_column_int(stmt, 0);
|
rec.id = sqlite3_column_int(stmt, 0);
|
||||||
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
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, 2);
|
||||||
rec.sort_order = sqlite3_column_int(stmt, 3);
|
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);
|
result.push_back(rec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,23 @@ struct ProclamationRecord {
|
|||||||
bool is_active = true;
|
bool is_active = true;
|
||||||
bool selected = false;
|
bool selected = false;
|
||||||
int category_id = 0; // maps to ProclamationCategoryRecord::id
|
int category_id = 0; // maps to ProclamationCategoryRecord::id
|
||||||
|
int group_id = 0; // maps to ProclamationGroupRecord::id (0 = ungrouped)
|
||||||
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
|
int solfeggio_frequency_id = 0; // maps to the solfeggio frequency preset
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ProclamationCategoryRecord {
|
struct ProclamationCategoryRecord {
|
||||||
int id = 0;
|
int id = 0;
|
||||||
std::string name;
|
std::string name;
|
||||||
int parent_id = 0; // 0 = root category
|
int sort_order = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A group is a tag-like organizer: it is not owned by a category. A
|
||||||
|
// proclamation belongs to one category and may additionally belong to one
|
||||||
|
// group. Within the UI, a group appears under a category only when at least
|
||||||
|
// one proclamation pairs that (category_id, group_id).
|
||||||
|
struct ProclamationGroupRecord {
|
||||||
|
int id = 0;
|
||||||
|
std::string name;
|
||||||
int sort_order = 0;
|
int sort_order = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +100,9 @@ public:
|
|||||||
// Full sync: replaces all categories with the given set.
|
// Full sync: replaces all categories with the given set.
|
||||||
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
|
bool replaceCategories(const std::vector<ProclamationCategoryRecord>& records);
|
||||||
|
|
||||||
|
// Full sync: replaces all groups with the given set.
|
||||||
|
bool replaceGroups(const std::vector<ProclamationGroupRecord>& records);
|
||||||
|
|
||||||
// Full sync: upserts proclamations, deletes any not in `records`,
|
// Full sync: upserts proclamations, deletes any not in `records`,
|
||||||
// preserves the existing `selected` state, and forces the proclamation
|
// preserves the existing `selected` state, and forces the proclamation
|
||||||
// identified by `defaultProclamationId` to remain selected.
|
// identified by `defaultProclamationId` to remain selected.
|
||||||
@@ -102,6 +115,9 @@ public:
|
|||||||
// Fetch all categories ordered by sort_order then id.
|
// Fetch all categories ordered by sort_order then id.
|
||||||
std::vector<ProclamationCategoryRecord> getAllCategories();
|
std::vector<ProclamationCategoryRecord> getAllCategories();
|
||||||
|
|
||||||
|
// Fetch all groups ordered by sort_order then id.
|
||||||
|
std::vector<ProclamationGroupRecord> getAllGroups();
|
||||||
|
|
||||||
// Toggle a single proclamation's selected flag.
|
// Toggle a single proclamation's selected flag.
|
||||||
bool setProclamationSelected(const std::string& id, bool selected);
|
bool setProclamationSelected(const std::string& id, bool selected);
|
||||||
|
|
||||||
@@ -144,6 +160,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::string resolvePath(const std::string& raw_path) const;
|
std::string resolvePath(const std::string& raw_path) const;
|
||||||
bool runMigrations();
|
bool runMigrations();
|
||||||
|
bool migrateSubCategoriesToGroups();
|
||||||
bool executeSql(const std::string& sql);
|
bool executeSql(const std::string& sql);
|
||||||
bool columnExists(const std::string& table, const std::string& column);
|
bool columnExists(const std::string& table, const std::string& column);
|
||||||
bool ensureColumn(const std::string& table, const std::string& column,
|
bool ensureColumn(const std::string& table, const std::string& column,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
|
|||||||
// Paths relative to API_BASE_URL.
|
// Paths relative to API_BASE_URL.
|
||||||
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
||||||
constexpr const char* PATH_CATEGORIES = "/categories";
|
constexpr const char* PATH_CATEGORIES = "/categories";
|
||||||
|
constexpr const char* PATH_GROUPS = "/groups";
|
||||||
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
||||||
constexpr const char* PATH_LICENSE = "/license";
|
constexpr const char* PATH_LICENSE = "/license";
|
||||||
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ bool parseProclamationsJson(const std::string& body,
|
|||||||
rec.title = stripHtml(entry.stringField("name"));
|
rec.title = stripHtml(entry.stringField("name"));
|
||||||
rec.content = stripHtml(entry.stringField("content"));
|
rec.content = stripHtml(entry.stringField("content"));
|
||||||
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
|
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
|
||||||
|
rec.group_id = static_cast<int>(entry.numberField("groupId"));
|
||||||
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
|
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
|
||||||
out.push_back(std::move(rec));
|
out.push_back(std::move(rec));
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,28 @@ bool parseCategoriesJson(const std::string& body,
|
|||||||
if (rec.id == 0) continue;
|
if (rec.id == 0) continue;
|
||||||
|
|
||||||
rec.name = entry.stringField("name");
|
rec.name = entry.stringField("name");
|
||||||
rec.parent_id = static_cast<int>(entry.numberField("parentId"));
|
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
||||||
|
out.push_back(std::move(rec));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseGroupsJson(const std::string& body,
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord>& out) {
|
||||||
|
json::JsonValue root;
|
||||||
|
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.clear();
|
||||||
|
for (const auto& entry : root.array) {
|
||||||
|
if (entry.type != json::JsonValue::Type::Object) continue;
|
||||||
|
|
||||||
|
radiantwave::db::ProclamationGroupRecord rec;
|
||||||
|
rec.id = static_cast<int>(entry.numberField("id"));
|
||||||
|
if (rec.id == 0) continue;
|
||||||
|
|
||||||
|
rec.name = entry.stringField("name");
|
||||||
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
|
||||||
out.push_back(std::move(rec));
|
out.push_back(std::move(rec));
|
||||||
}
|
}
|
||||||
@@ -114,16 +136,28 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api::HttpResponse groupsResponse =
|
||||||
|
api::httpGet(api::PATH_GROUPS);
|
||||||
|
if (!groupsResponse.transportOk ||
|
||||||
|
groupsResponse.status < 200 || groupsResponse.status >= 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<radiantwave::db::ProclamationRecord> proclamations;
|
std::vector<radiantwave::db::ProclamationRecord> proclamations;
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||||
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
||||||
!parseCategoriesJson(categoriesResponse.body, categories)) {
|
!parseCategoriesJson(categoriesResponse.body, categories) ||
|
||||||
|
!parseGroupsJson(groupsResponse.body, groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db.replaceCategories(categories)) {
|
if (!db.replaceCategories(categories)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!db.replaceGroups(groups)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ bool parseProclamationsJson(const std::string& body,
|
|||||||
bool parseCategoriesJson(const std::string& body,
|
bool parseCategoriesJson(const std::string& body,
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
||||||
|
|
||||||
// Fetches proclamations + categories from the API and stores them in the DB,
|
// Parse the JSON payload returned by the groups endpoint.
|
||||||
// preserving any existing selection and forcing `defaultProclamationId` to
|
bool parseGroupsJson(const std::string& body,
|
||||||
// stay selected. Returns true only if the API sync succeeded; on failure the
|
std::vector<radiantwave::db::ProclamationGroupRecord>& out);
|
||||||
|
|
||||||
|
// Fetches proclamations + categories + groups from the API and stores them in
|
||||||
|
// the DB, preserving any existing selection and forcing `defaultProclamationId`
|
||||||
|
// to stay selected. Returns true only if the API sync succeeded; on failure the
|
||||||
// database is left untouched so the app can fall back to cached data.
|
// database is left untouched so the app can fall back to cached data.
|
||||||
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||||
const std::string& defaultProclamationId);
|
const std::string& defaultProclamationId);
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
|
|||||||
{
|
{
|
||||||
proclamations_ = state_->db.getAllProclamations();
|
proclamations_ = state_->db.getAllProclamations();
|
||||||
categories_ = state_->db.getAllCategories();
|
categories_ = state_->db.getAllCategories();
|
||||||
|
groups_ = state_->db.getAllGroups();
|
||||||
|
|
||||||
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
||||||
for (auto& p : proclamations_) {
|
for (auto& p : proclamations_) {
|
||||||
@@ -181,42 +182,55 @@ void ProclamationOptionsPage::rebuildItems()
|
|||||||
0, false, true, true,
|
0, false, true, true,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const auto& root : categories_) {
|
// Groups that actually exist, so a proclamation pointing at a group the
|
||||||
if (root.parent_id != 0) continue;
|
// portal has since removed falls back to the ungrouped bucket.
|
||||||
|
auto groupExists = [this](int groupId) {
|
||||||
|
return std::any_of(groups_.begin(), groups_.end(),
|
||||||
|
[groupId](const auto& g) { return g.id == groupId; });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto& category : categories_) {
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::RootCategory,
|
FlatItem::Type::Category,
|
||||||
std::to_string(root.id),
|
std::to_string(category.id),
|
||||||
root.name,
|
category.name,
|
||||||
0, false, false, false,
|
0, false, false, false,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const auto& sub : categories_) {
|
// Groups are tag-like and global. A group is shown under a category
|
||||||
if (sub.parent_id != root.id) continue;
|
// only when it has at least one proclamation in that category, so
|
||||||
|
// impossible pairings (e.g. "Physical / Prayers") never render.
|
||||||
|
for (const auto& group : groups_) {
|
||||||
|
std::vector<const radiantwave::db::ProclamationRecord*> members;
|
||||||
|
for (const auto& p : proclamations_) {
|
||||||
|
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
||||||
|
if (p.category_id != category.id || p.group_id != group.id) continue;
|
||||||
|
members.push_back(&p);
|
||||||
|
}
|
||||||
|
if (members.empty()) continue;
|
||||||
|
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::SubCategory,
|
FlatItem::Type::Group,
|
||||||
std::to_string(sub.id),
|
std::to_string(group.id),
|
||||||
sub.name,
|
group.name,
|
||||||
1, true, false, false,
|
1, true, false, false,
|
||||||
});
|
});
|
||||||
|
for (const auto* p : members) {
|
||||||
for (const auto& p : proclamations_) {
|
|
||||||
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
|
||||||
if (p.category_id != sub.id) continue;
|
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::Proclamation,
|
FlatItem::Type::Proclamation,
|
||||||
p.id,
|
p->id,
|
||||||
p.title,
|
p->title,
|
||||||
2, false, p.selected, false,
|
2, false, p->selected, false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proclamations assigned directly to the root category.
|
// Proclamations with no group (or a stale group id) sit directly under
|
||||||
|
// the category.
|
||||||
for (const auto& p : proclamations_) {
|
for (const auto& p : proclamations_) {
|
||||||
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
if (p.id == DEFAULT_PROPLEAMATION_ID) continue;
|
||||||
if (p.category_id != root.id) continue;
|
if (p.category_id != category.id) continue;
|
||||||
|
if (p.group_id != 0 && groupExists(p.group_id)) continue;
|
||||||
items_.push_back({
|
items_.push_back({
|
||||||
FlatItem::Type::Proclamation,
|
FlatItem::Type::Proclamation,
|
||||||
p.id,
|
p.id,
|
||||||
@@ -235,29 +249,29 @@ void ProclamationOptionsPage::rebuildVisible()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tree walk: track the currently-open root and sub category (items_ is
|
// Tree walk: track the currently-open category and group (items_ is
|
||||||
// already ordered root -> sub -> proclamation, so each category dominates
|
// already ordered category -> group -> proclamation, so each category
|
||||||
// the items until the next category of equal or shallower depth).
|
// dominates the items until the next category).
|
||||||
int currentRoot = -1; // items_ index of the current root category
|
int currentCategory = -1; // items_ index of the current category
|
||||||
int currentSub = -1; // items_ index of the current sub-category
|
int currentGroup = -1; // items_ index of the current group
|
||||||
|
|
||||||
for (size_t i = 0; i < items_.size(); ++i) {
|
for (size_t i = 0; i < items_.size(); ++i) {
|
||||||
const FlatItem& item = items_[i];
|
const FlatItem& item = items_[i];
|
||||||
|
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category) {
|
||||||
currentRoot = static_cast<int>(i);
|
currentCategory = static_cast<int>(i);
|
||||||
currentSub = -1;
|
currentGroup = -1;
|
||||||
visible_.push_back(i);
|
visible_.push_back(i);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::Group) {
|
||||||
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
|
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
|
||||||
if (rootOpen) {
|
if (categoryOpen) {
|
||||||
currentSub = static_cast<int>(i);
|
currentGroup = static_cast<int>(i);
|
||||||
visible_.push_back(i);
|
visible_.push_back(i);
|
||||||
} else {
|
} else {
|
||||||
currentSub = -1;
|
currentGroup = -1;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -266,9 +280,10 @@ void ProclamationOptionsPage::rebuildVisible()
|
|||||||
bool parentVisible = false;
|
bool parentVisible = false;
|
||||||
if (item.depth == 0) {
|
if (item.depth == 0) {
|
||||||
parentVisible = true; // the always-on top-level proclamation
|
parentVisible = true; // the always-on top-level proclamation
|
||||||
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
|
} else if (currentCategory >= 0 && items_[currentCategory].expanded) {
|
||||||
parentVisible = (item.depth == 1) ||
|
parentVisible = (item.depth == 1) ||
|
||||||
(item.depth == 2 && currentSub >= 0);
|
(item.depth == 2 && currentGroup >= 0 &&
|
||||||
|
items_[currentGroup].expanded);
|
||||||
}
|
}
|
||||||
if (parentVisible) visible_.push_back(i);
|
if (parentVisible) visible_.push_back(i);
|
||||||
}
|
}
|
||||||
@@ -283,7 +298,7 @@ void ProclamationOptionsPage::toggleItem()
|
|||||||
if (visible_.empty()) return;
|
if (visible_.empty()) return;
|
||||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
|
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||||
item.expanded = !item.expanded;
|
item.expanded = !item.expanded;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
return;
|
return;
|
||||||
@@ -388,7 +403,7 @@ void ProclamationOptionsPage::collapseCurrent()
|
|||||||
{
|
{
|
||||||
if (visible_.empty()) return;
|
if (visible_.empty()) return;
|
||||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||||
if (item.expanded) {
|
if (item.expanded) {
|
||||||
item.expanded = false;
|
item.expanded = false;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
@@ -400,7 +415,7 @@ void ProclamationOptionsPage::expandCurrent()
|
|||||||
{
|
{
|
||||||
if (visible_.empty()) return;
|
if (visible_.empty()) return;
|
||||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||||
if (!item.expanded) {
|
if (!item.expanded) {
|
||||||
item.expanded = true;
|
item.expanded = true;
|
||||||
rebuildVisible();
|
rebuildVisible();
|
||||||
@@ -417,8 +432,8 @@ void ProclamationOptionsPage::moveToParent()
|
|||||||
int start = static_cast<int>(visible_[highlightIndex_]);
|
int start = static_cast<int>(visible_[highlightIndex_]);
|
||||||
for (int i = start - 1; i >= 0; --i) {
|
for (int i = start - 1; i >= 0; --i) {
|
||||||
const FlatItem& candidate = items_[i];
|
const FlatItem& candidate = items_[i];
|
||||||
if ((candidate.type == FlatItem::Type::RootCategory ||
|
if ((candidate.type == FlatItem::Type::Category ||
|
||||||
candidate.type == FlatItem::Type::SubCategory) &&
|
candidate.type == FlatItem::Type::Group) &&
|
||||||
candidate.depth == item.depth - 1) {
|
candidate.depth == item.depth - 1) {
|
||||||
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
||||||
if (visible_[vi] == static_cast<size_t>(i)) {
|
if (visible_[vi] == static_cast<size_t>(i)) {
|
||||||
@@ -526,7 +541,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
|
|||||||
if (searchActive_) return;
|
if (searchActive_) return;
|
||||||
if (!visible_.empty()) {
|
if (!visible_.empty()) {
|
||||||
const FlatItem& item = items_[visible_[highlightIndex_]];
|
const FlatItem& item = items_[visible_[highlightIndex_]];
|
||||||
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
|
if ((item.type == FlatItem::Type::Category ||
|
||||||
|
item.type == FlatItem::Type::Group) && item.expanded) {
|
||||||
collapseCurrent();
|
collapseCurrent();
|
||||||
} else {
|
} else {
|
||||||
moveToParent();
|
moveToParent();
|
||||||
@@ -567,6 +583,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
SDL_Color white = {255, 255, 255, 255};
|
SDL_Color white = {255, 255, 255, 255};
|
||||||
SDL_Color gray = {170, 170, 170, 255};
|
SDL_Color gray = {170, 170, 170, 255};
|
||||||
SDL_Color categoryBlue = {100, 180, 255, 255};
|
SDL_Color categoryBlue = {100, 180, 255, 255};
|
||||||
|
SDL_Color groupBlue = {160, 210, 255, 255};
|
||||||
SDL_Color selectedOrange = {255, 170, 60, 255};
|
SDL_Color selectedOrange = {255, 170, 60, 255};
|
||||||
|
|
||||||
float titleY = 50.0f;
|
float titleY = 50.0f;
|
||||||
@@ -703,7 +720,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
float maxTotalWidth = 0.0f;
|
float maxTotalWidth = 0.0f;
|
||||||
for (const auto& item : items_) {
|
for (const auto& item : items_) {
|
||||||
std::string label = item.label;
|
std::string label = item.label;
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
}
|
}
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
@@ -716,7 +733,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
||||||
const FlatItem& item = items_[visible_[i]];
|
const FlatItem& item = items_[visible_[i]];
|
||||||
std::string label = item.label;
|
std::string label = item.label;
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
}
|
}
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
@@ -734,13 +751,15 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
|||||||
float y = listTop + rowIndex * rowPitch;
|
float y = listTop + rowIndex * rowPitch;
|
||||||
|
|
||||||
std::string label = item.label;
|
std::string label = item.label;
|
||||||
if (item.type == FlatItem::Type::RootCategory) {
|
if (item.type == FlatItem::Type::Category || item.type == FlatItem::Type::Group) {
|
||||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||||
}
|
}
|
||||||
|
|
||||||
SDL_Color color = white;
|
SDL_Color color = white;
|
||||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
if (item.type == FlatItem::Type::Category) {
|
||||||
color = categoryBlue;
|
color = categoryBlue;
|
||||||
|
} else if (item.type == FlatItem::Type::Group) {
|
||||||
|
color = groupBlue;
|
||||||
} else if (item.selected || item.isDefault) {
|
} else if (item.selected || item.isDefault) {
|
||||||
color = selectedOrange;
|
color = selectedOrange;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
struct FlatItem {
|
struct FlatItem {
|
||||||
enum class Type { RootCategory, SubCategory, Proclamation };
|
enum class Type { Category, Group, Proclamation };
|
||||||
|
|
||||||
Type type = Type::Proclamation;
|
Type type = Type::Proclamation;
|
||||||
std::string id; // category id or proclamation id
|
std::string id; // category id, group id, or proclamation id
|
||||||
std::string label; // display text
|
std::string label; // display text
|
||||||
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
|
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
|
||||||
bool expanded = false; // categories only
|
bool expanded = false; // categories and groups only
|
||||||
bool selected = false; // proclamations only
|
bool selected = false; // proclamations only
|
||||||
bool isDefault = false; // the always-on top-level proclamation
|
bool isDefault = false; // the always-on top-level proclamation
|
||||||
};
|
};
|
||||||
@@ -67,6 +67,7 @@ private:
|
|||||||
AppState* state_ = nullptr;
|
AppState* state_ = nullptr;
|
||||||
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
|
||||||
std::vector<FlatItem> items_;
|
std::vector<FlatItem> items_;
|
||||||
std::vector<size_t> visible_; // indices into items_, in display order
|
std::vector<size_t> visible_; // indices into items_, in display order
|
||||||
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
||||||
|
|||||||
+27
-10
@@ -142,16 +142,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) {
|
|||||||
EXPECT_EQ(out[1].category_id, 32);
|
EXPECT_EQ(out[1].category_id, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
|
TEST(ApiClientTest, ParseFlatCategoriesAndGroups) {
|
||||||
const std::string body =
|
const std::string categoriesBody =
|
||||||
R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null},
|
R"([{"id":4,"name":"Spiritual","sortOrder":0},
|
||||||
{"id":5,"name":"General","sortOrder":0,"parentId":4}])";
|
{"id":5,"name":"General","sortOrder":1}])";
|
||||||
|
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> out;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out));
|
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories));
|
||||||
ASSERT_EQ(out.size(), 2u);
|
ASSERT_EQ(categories.size(), 2u);
|
||||||
EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root)
|
EXPECT_EQ(categories[0].id, 4);
|
||||||
EXPECT_EQ(out[1].parent_id, 4);
|
EXPECT_EQ(categories[1].id, 5);
|
||||||
|
|
||||||
|
const std::string groupsBody =
|
||||||
|
R"([{"id":9,"name":"Prayers","sortOrder":0}])";
|
||||||
|
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||||
|
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsBody, groups));
|
||||||
|
ASSERT_EQ(groups.size(), 1u);
|
||||||
|
EXPECT_EQ(groups[0].id, 9);
|
||||||
|
EXPECT_EQ(groups[0].name, "Prayers");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
|
// ─── Live: GET /frequencies (harmless, always runs) ───────────────────
|
||||||
@@ -194,7 +203,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
|
|||||||
|
|
||||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
|
||||||
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(catsResponse.body, categories));
|
||||||
EXPECT_GE(categories.size(), 20u);
|
EXPECT_FALSE(categories.empty());
|
||||||
|
|
||||||
|
HttpResponse groupsResponse = httpGet(PATH_GROUPS);
|
||||||
|
ASSERT_TRUE(groupsResponse.transportOk);
|
||||||
|
ASSERT_EQ(groupsResponse.status, 200);
|
||||||
|
|
||||||
|
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||||
|
EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups));
|
||||||
|
EXPECT_FALSE(groups.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Live: POST /license error paths (no credentials required) ─────────
|
// ─── Live: POST /license error paths (no credentials required) ─────────
|
||||||
|
|||||||
+100
-4
@@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) {
|
|||||||
DbFixture fx("test_rw_db_cats.db");
|
DbFixture fx("test_rw_db_cats.db");
|
||||||
|
|
||||||
std::vector<ProclamationCategoryRecord> cats;
|
std::vector<ProclamationCategoryRecord> cats;
|
||||||
cats.push_back({3, "Root", 0, 1});
|
cats.push_back({3, "Spiritual", 1});
|
||||||
cats.push_back({7, "Child", 3, 2});
|
cats.push_back({7, "Physical", 2});
|
||||||
|
|
||||||
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
ASSERT_TRUE(fx.db.replaceCategories(cats));
|
||||||
|
|
||||||
auto all = fx.db.getAllCategories();
|
auto all = fx.db.getAllCategories();
|
||||||
ASSERT_EQ(all.size(), 2u);
|
ASSERT_EQ(all.size(), 2u);
|
||||||
EXPECT_EQ(all[0].id, 3);
|
EXPECT_EQ(all[0].id, 3);
|
||||||
EXPECT_EQ(all[0].parent_id, 0);
|
EXPECT_EQ(all[0].name, "Spiritual");
|
||||||
|
EXPECT_EQ(all[0].sort_order, 1);
|
||||||
EXPECT_EQ(all[1].id, 7);
|
EXPECT_EQ(all[1].id, 7);
|
||||||
EXPECT_EQ(all[1].parent_id, 3);
|
EXPECT_EQ(all[1].name, "Physical");
|
||||||
|
EXPECT_EQ(all[1].sort_order, 2);
|
||||||
|
|
||||||
// Replacing wipes previous categories.
|
// Replacing wipes previous categories.
|
||||||
ASSERT_TRUE(fx.db.replaceCategories({}));
|
ASSERT_TRUE(fx.db.replaceCategories({}));
|
||||||
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
EXPECT_EQ(fx.db.getAllCategories().size(), 0u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(DatabaseManagerTest, ReplaceAndReadGroups) {
|
||||||
|
DbFixture fx("test_rw_db_groups.db");
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> groups;
|
||||||
|
groups.push_back({10, "Prayers", 2});
|
||||||
|
groups.push_back({4, "Affirmations", 1});
|
||||||
|
|
||||||
|
ASSERT_TRUE(fx.db.replaceGroups(groups));
|
||||||
|
|
||||||
|
auto all = fx.db.getAllGroups();
|
||||||
|
ASSERT_EQ(all.size(), 2u);
|
||||||
|
// Ordered by sort_order, not by id.
|
||||||
|
EXPECT_EQ(all[0].id, 4);
|
||||||
|
EXPECT_EQ(all[0].name, "Affirmations");
|
||||||
|
EXPECT_EQ(all[1].id, 10);
|
||||||
|
EXPECT_EQ(all[1].name, "Prayers");
|
||||||
|
|
||||||
|
ASSERT_TRUE(fx.db.replaceGroups({}));
|
||||||
|
EXPECT_EQ(fx.db.getAllGroups().size(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatabaseManagerTest, MigratesLegacySubCategoriesToGroups) {
|
||||||
|
const std::string path =
|
||||||
|
(std::filesystem::temp_directory_path() / "test_rw_db_migrate_v1.db").string();
|
||||||
|
if (std::filesystem::exists(path)) {
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a pre-v2 database by hand: a two-level category tree and a
|
||||||
|
// proclamation selected inside a sub-category.
|
||||||
|
{
|
||||||
|
sqlite3* raw = nullptr;
|
||||||
|
ASSERT_EQ(sqlite3_open(path.c_str(), &raw), SQLITE_OK);
|
||||||
|
const char* legacy =
|
||||||
|
"CREATE TABLE 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,"
|
||||||
|
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP);"
|
||||||
|
"CREATE TABLE proclamation_categories ("
|
||||||
|
" id INTEGER PRIMARY KEY, name TEXT NOT NULL,"
|
||||||
|
" parent_id INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0);"
|
||||||
|
"INSERT INTO proclamation_categories (id, name, parent_id, sort_order)"
|
||||||
|
" VALUES (3,'Spiritual',0,1),(7,'Inner',3,2);"
|
||||||
|
"INSERT INTO proclamations (id,title,content,category_id,solfeggio_frequency_id,selected)"
|
||||||
|
" VALUES ('p1','Peace','x',7,2,1),('p2','Root-level','y',3,5,0);";
|
||||||
|
char* err = nullptr;
|
||||||
|
int rc = sqlite3_exec(raw, legacy, nullptr, nullptr, &err);
|
||||||
|
if (err) sqlite3_free(err);
|
||||||
|
sqlite3_close(raw);
|
||||||
|
ASSERT_EQ(rc, SQLITE_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseManager db;
|
||||||
|
ASSERT_TRUE(db.initialize(path));
|
||||||
|
|
||||||
|
// Each former sub-category becomes a group; only the root remains a category.
|
||||||
|
auto groups = db.getAllGroups();
|
||||||
|
ASSERT_EQ(groups.size(), 1u);
|
||||||
|
EXPECT_EQ(groups[0].id, 7);
|
||||||
|
EXPECT_EQ(groups[0].name, "Inner");
|
||||||
|
|
||||||
|
auto cats = db.getAllCategories();
|
||||||
|
ASSERT_EQ(cats.size(), 1u);
|
||||||
|
EXPECT_EQ(cats[0].id, 3);
|
||||||
|
EXPECT_EQ(cats[0].name, "Spiritual");
|
||||||
|
|
||||||
|
// Proclamations pointing at the old sub-category are remapped to the root
|
||||||
|
// category + the new group; selection is preserved.
|
||||||
|
auto procs = db.getAllProclamations();
|
||||||
|
ASSERT_EQ(procs.size(), 2u);
|
||||||
|
for (const auto& p : procs) {
|
||||||
|
EXPECT_EQ(p.category_id, 3) << "proclamation " << p.id;
|
||||||
|
if (p.id == "p1") {
|
||||||
|
EXPECT_EQ(p.group_id, 7);
|
||||||
|
EXPECT_TRUE(p.selected);
|
||||||
|
} else if (p.id == "p2") {
|
||||||
|
EXPECT_EQ(p.group_id, 0);
|
||||||
|
EXPECT_FALSE(p.selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-running the migration is a no-op (user_version gate + idempotent SQL).
|
||||||
|
db.close();
|
||||||
|
ASSERT_TRUE(db.initialize(path));
|
||||||
|
EXPECT_EQ(db.getAllGroups().size(), 1u);
|
||||||
|
EXPECT_EQ(db.getAllCategories().size(), 1u);
|
||||||
|
EXPECT_EQ(db.getAllProclamations().size(), 2u);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
std::filesystem::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
TEST(DatabaseManagerTest, SyncAudioFilesUpsertsAndDeletesMissing) {
|
||||||
DbFixture fx("test_rw_db_audio.db");
|
DbFixture fx("test_rw_db_audio.db");
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ using namespace radiantwave::sync;
|
|||||||
|
|
||||||
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
||||||
const std::string body =
|
const std::string body =
|
||||||
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"solfeggioFrequencyId":2},
|
R"([{"id":"p1","name":"Peace","content":"<p>I am <b>peace</b>.</p>","categoryId":3,"groupId":7,"solfeggioFrequencyId":2},
|
||||||
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0},
|
{"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0},
|
||||||
{"notAnObject":true}])";
|
{"notAnObject":true}])";
|
||||||
|
|
||||||
std::vector<ProclamationRecord> out;
|
std::vector<ProclamationRecord> out;
|
||||||
@@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) {
|
|||||||
EXPECT_EQ(out[0].title, "Peace");
|
EXPECT_EQ(out[0].title, "Peace");
|
||||||
EXPECT_EQ(out[0].content, "I am peace.");
|
EXPECT_EQ(out[0].content, "I am peace.");
|
||||||
EXPECT_EQ(out[0].category_id, 3);
|
EXPECT_EQ(out[0].category_id, 3);
|
||||||
|
EXPECT_EQ(out[0].group_id, 7);
|
||||||
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
|
EXPECT_EQ(out[0].solfeggio_frequency_id, 2);
|
||||||
|
|
||||||
EXPECT_EQ(out[1].id, "p2");
|
EXPECT_EQ(out[1].id, "p2");
|
||||||
EXPECT_EQ(out[1].title, "Joy & Calm");
|
EXPECT_EQ(out[1].title, "Joy & Calm");
|
||||||
EXPECT_EQ(out[1].content, "text");
|
EXPECT_EQ(out[1].content, "text");
|
||||||
EXPECT_EQ(out[1].category_id, 0);
|
EXPECT_EQ(out[1].category_id, 0);
|
||||||
|
EXPECT_EQ(out[1].group_id, 0);
|
||||||
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
|
EXPECT_EQ(out[1].solfeggio_frequency_id, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
||||||
|
// Categories are flat now; a legacy parentId field is simply ignored.
|
||||||
const std::string body =
|
const std::string body =
|
||||||
R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1},
|
R"([{"id":3,"name":"Spiritual","sortOrder":1},
|
||||||
{"id":7,"name":"Inner","parentId":3,"sortOrder":2}])";
|
{"id":7,"name":"Physical","parentId":3,"sortOrder":2}])";
|
||||||
|
|
||||||
std::vector<ProclamationCategoryRecord> out;
|
std::vector<ProclamationCategoryRecord> out;
|
||||||
EXPECT_TRUE(parseCategoriesJson(body, out));
|
EXPECT_TRUE(parseCategoriesJson(body, out));
|
||||||
@@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) {
|
|||||||
|
|
||||||
EXPECT_EQ(out[0].id, 3);
|
EXPECT_EQ(out[0].id, 3);
|
||||||
EXPECT_EQ(out[0].name, "Spiritual");
|
EXPECT_EQ(out[0].name, "Spiritual");
|
||||||
EXPECT_EQ(out[0].parent_id, 0);
|
|
||||||
EXPECT_EQ(out[0].sort_order, 1);
|
EXPECT_EQ(out[0].sort_order, 1);
|
||||||
|
|
||||||
EXPECT_EQ(out[1].id, 7);
|
EXPECT_EQ(out[1].id, 7);
|
||||||
EXPECT_EQ(out[1].name, "Inner");
|
EXPECT_EQ(out[1].name, "Physical");
|
||||||
EXPECT_EQ(out[1].parent_id, 3);
|
|
||||||
EXPECT_EQ(out[1].sort_order, 2);
|
EXPECT_EQ(out[1].sort_order, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(ProclamationSyncTest, ParseGroupsJson) {
|
||||||
|
const std::string body =
|
||||||
|
R"([{"id":5,"name":"Prayers","sortOrder":0},
|
||||||
|
{"id":8,"name":"Affirmations","sortOrder":3}])";
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> out;
|
||||||
|
EXPECT_TRUE(parseGroupsJson(body, out));
|
||||||
|
ASSERT_EQ(out.size(), 2u);
|
||||||
|
|
||||||
|
EXPECT_EQ(out[0].id, 5);
|
||||||
|
EXPECT_EQ(out[0].name, "Prayers");
|
||||||
|
EXPECT_EQ(out[0].sort_order, 0);
|
||||||
|
|
||||||
|
EXPECT_EQ(out[1].id, 8);
|
||||||
|
EXPECT_EQ(out[1].name, "Affirmations");
|
||||||
|
EXPECT_EQ(out[1].sort_order, 3);
|
||||||
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, RejectMalformedJson) {
|
TEST(ProclamationSyncTest, RejectMalformedJson) {
|
||||||
std::vector<ProclamationRecord> procs;
|
std::vector<ProclamationRecord> procs;
|
||||||
std::vector<ProclamationCategoryRecord> cats;
|
std::vector<ProclamationCategoryRecord> cats;
|
||||||
|
std::vector<ProclamationGroupRecord> groups;
|
||||||
|
|
||||||
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
EXPECT_FALSE(parseProclamationsJson("not json", procs));
|
||||||
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
|
EXPECT_FALSE(parseProclamationsJson(R"({"id":"p1"})", procs)); // object, not array
|
||||||
@@ -69,6 +89,10 @@ TEST(ProclamationSyncTest, RejectMalformedJson) {
|
|||||||
// Non-numeric ids are tolerated (row skipped), not fatal.
|
// Non-numeric ids are tolerated (row skipped), not fatal.
|
||||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats));
|
||||||
EXPECT_EQ(cats.size(), 0u);
|
EXPECT_EQ(cats.size(), 0u);
|
||||||
|
|
||||||
|
EXPECT_FALSE(parseGroupsJson("garbage", groups));
|
||||||
|
EXPECT_TRUE(parseGroupsJson(R"([{"id":"x"}])", groups));
|
||||||
|
EXPECT_EQ(groups.size(), 0u);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
||||||
@@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) {
|
|||||||
EXPECT_EQ(procs[0].id, "ok");
|
EXPECT_EQ(procs[0].id, "ok");
|
||||||
|
|
||||||
std::vector<ProclamationCategoryRecord> cats;
|
std::vector<ProclamationCategoryRecord> cats;
|
||||||
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","parentId":0}])", cats));
|
EXPECT_TRUE(parseCategoriesJson(R"([{"id":0,"name":"Bad"},{"id":9,"name":"Good","sortOrder":1}])", cats));
|
||||||
ASSERT_EQ(cats.size(), 1u);
|
ASSERT_EQ(cats.size(), 1u);
|
||||||
EXPECT_EQ(cats[0].id, 9);
|
EXPECT_EQ(cats[0].id, 9);
|
||||||
|
|
||||||
|
std::vector<ProclamationGroupRecord> groups;
|
||||||
|
EXPECT_TRUE(parseGroupsJson(R"([{"id":0,"name":"Bad"},{"id":4,"name":"Good","sortOrder":1}])", groups));
|
||||||
|
ASSERT_EQ(groups.size(), 1u);
|
||||||
|
EXPECT_EQ(groups[0].id, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST(ProclamationSyncTest, StripHtmlEntities) {
|
TEST(ProclamationSyncTest, StripHtmlEntities) {
|
||||||
|
|||||||
Reference in New Issue
Block a user