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:
+153
-18
@@ -131,6 +131,7 @@ bool DatabaseManager::runMigrations() {
|
||||
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
|
||||
);
|
||||
@@ -138,7 +139,12 @@ bool DatabaseManager::runMigrations() {
|
||||
CREATE TABLE IF NOT EXISTS proclamation_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proclamation_groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
@@ -182,6 +188,7 @@ bool DatabaseManager::runMigrations() {
|
||||
// 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.
|
||||
@@ -189,12 +196,72 @@ bool DatabaseManager::runMigrations() {
|
||||
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 "
|
||||
@@ -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, 7, record.selected ? 1 : 0);
|
||||
sqlite3_bind_int(stmt, 8, record.category_id);
|
||||
sqlite3_bind_int(stmt, 9, record.solfeggio_frequency_id);
|
||||
sqlite3_bind_int(stmt, 9, record.group_id);
|
||||
sqlite3_bind_int(stmt, 10, record.solfeggio_frequency_id);
|
||||
}
|
||||
|
||||
bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||
@@ -345,12 +413,13 @@ bool DatabaseManager::saveProclamation(const ProclamationRecord& record) {
|
||||
if (!db_) return false;
|
||||
|
||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"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, 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;
|
||||
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.selected = (sqlite3_column_int(stmt, firstCol + 6) != 0);
|
||||
rec.category_id = sqlite3_column_int(stmt, firstCol + 7);
|
||||
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 8);
|
||||
rec.group_id = sqlite3_column_int(stmt, firstCol + 8);
|
||||
rec.solfeggio_frequency_id = sqlite3_column_int(stmt, firstCol + 9);
|
||||
return rec;
|
||||
}
|
||||
|
||||
@@ -384,7 +454,7 @@ std::vector<ProclamationRecord> DatabaseManager::getActiveProclamations() {
|
||||
if (!db_) return result;
|
||||
|
||||
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;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -414,8 +484,8 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* insSql = "INSERT INTO proclamation_categories (id, name, parent_id, sort_order) "
|
||||
"VALUES (?, ?, ?, ?);";
|
||||
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);
|
||||
@@ -425,8 +495,50 @@ bool DatabaseManager::replaceCategories(const std::vector<ProclamationCategoryRe
|
||||
for (const auto& cat : records) {
|
||||
sqlite3_bind_int(stmt, 1, cat.id);
|
||||
sqlite3_bind_text(stmt, 2, cat.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 3, cat.parent_id);
|
||||
sqlite3_bind_int(stmt, 4, cat.sort_order);
|
||||
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;
|
||||
@@ -499,12 +611,12 @@ bool DatabaseManager::syncProclamations(const std::vector<ProclamationRecord>& r
|
||||
// Upsert each proclamation, preserving the stashed selection state.
|
||||
if (ok) {
|
||||
const char* sql = "INSERT INTO proclamations (id, title, content, solfeggio_freq_hz, category, "
|
||||
"is_active, selected, category_id, solfeggio_frequency_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"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, "
|
||||
"solfeggio_frequency_id=excluded.solfeggio_frequency_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) {
|
||||
@@ -555,7 +667,7 @@ std::vector<ProclamationRecord> DatabaseManager::getAllProclamations() {
|
||||
if (!db_) return result;
|
||||
|
||||
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;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return result;
|
||||
@@ -574,7 +686,7 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
||||
std::vector<ProclamationCategoryRecord> 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;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
@@ -585,8 +697,31 @@ std::vector<ProclamationCategoryRecord> DatabaseManager::getAllCategories() {
|
||||
ProclamationCategoryRecord rec;
|
||||
rec.id = sqlite3_column_int(stmt, 0);
|
||||
rec.name = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
rec.parent_id = sqlite3_column_int(stmt, 2);
|
||||
rec.sort_order = sqlite3_column_int(stmt, 3);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,23 @@ struct ProclamationRecord {
|
||||
bool is_active = true;
|
||||
bool selected = false;
|
||||
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
|
||||
};
|
||||
|
||||
struct ProclamationCategoryRecord {
|
||||
int id = 0;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -90,6 +100,9 @@ public:
|
||||
// Full sync: replaces all categories with the given set.
|
||||
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`,
|
||||
// preserves the existing `selected` state, and forces the proclamation
|
||||
// identified by `defaultProclamationId` to remain selected.
|
||||
@@ -102,6 +115,9 @@ public:
|
||||
// Fetch all categories ordered by sort_order then id.
|
||||
std::vector<ProclamationCategoryRecord> getAllCategories();
|
||||
|
||||
// Fetch all groups ordered by sort_order then id.
|
||||
std::vector<ProclamationGroupRecord> getAllGroups();
|
||||
|
||||
// Toggle a single proclamation's selected flag.
|
||||
bool setProclamationSelected(const std::string& id, bool selected);
|
||||
|
||||
@@ -144,6 +160,7 @@ public:
|
||||
private:
|
||||
std::string resolvePath(const std::string& raw_path) const;
|
||||
bool runMigrations();
|
||||
bool migrateSubCategoriesToGroups();
|
||||
bool executeSql(const std::string& sql);
|
||||
bool columnExists(const std::string& table, const std::string& column);
|
||||
bool ensureColumn(const std::string& table, const std::string& column,
|
||||
|
||||
@@ -12,6 +12,7 @@ constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"
|
||||
// Paths relative to API_BASE_URL.
|
||||
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
|
||||
constexpr const char* PATH_CATEGORIES = "/categories";
|
||||
constexpr const char* PATH_GROUPS = "/groups";
|
||||
constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
||||
constexpr const char* PATH_LICENSE = "/license";
|
||||
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.content = stripHtml(entry.stringField("content"));
|
||||
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"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
@@ -88,7 +89,28 @@ bool parseCategoriesJson(const std::string& body,
|
||||
if (rec.id == 0) continue;
|
||||
|
||||
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"));
|
||||
out.push_back(std::move(rec));
|
||||
}
|
||||
@@ -114,16 +136,28 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
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::ProclamationCategoryRecord> categories;
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups;
|
||||
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
|
||||
!parseCategoriesJson(categoriesResponse.body, categories)) {
|
||||
!parseCategoriesJson(categoriesResponse.body, categories) ||
|
||||
!parseGroupsJson(groupsResponse.body, groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!db.replaceCategories(categories)) {
|
||||
return false;
|
||||
}
|
||||
if (!db.replaceGroups(groups)) {
|
||||
return false;
|
||||
}
|
||||
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -15,9 +15,13 @@ bool parseProclamationsJson(const std::string& body,
|
||||
bool parseCategoriesJson(const std::string& body,
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
|
||||
|
||||
// Fetches proclamations + categories 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
|
||||
// Parse the JSON payload returned by the groups endpoint.
|
||||
bool parseGroupsJson(const std::string& body,
|
||||
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.
|
||||
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
const std::string& defaultProclamationId);
|
||||
|
||||
@@ -148,6 +148,7 @@ void ProclamationOptionsPage::loadFromDb()
|
||||
{
|
||||
proclamations_ = state_->db.getAllProclamations();
|
||||
categories_ = state_->db.getAllCategories();
|
||||
groups_ = state_->db.getAllGroups();
|
||||
|
||||
state_->db.ensureDefaultProclamationSelected(DEFAULT_PROPLEAMATION_ID);
|
||||
for (auto& p : proclamations_) {
|
||||
@@ -181,42 +182,55 @@ void ProclamationOptionsPage::rebuildItems()
|
||||
0, false, true, true,
|
||||
});
|
||||
|
||||
for (const auto& root : categories_) {
|
||||
if (root.parent_id != 0) continue;
|
||||
// Groups that actually exist, so a proclamation pointing at a group the
|
||||
// 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({
|
||||
FlatItem::Type::RootCategory,
|
||||
std::to_string(root.id),
|
||||
root.name,
|
||||
FlatItem::Type::Category,
|
||||
std::to_string(category.id),
|
||||
category.name,
|
||||
0, false, false, false,
|
||||
});
|
||||
|
||||
for (const auto& sub : categories_) {
|
||||
if (sub.parent_id != root.id) continue;
|
||||
|
||||
items_.push_back({
|
||||
FlatItem::Type::SubCategory,
|
||||
std::to_string(sub.id),
|
||||
sub.name,
|
||||
1, true, false, false,
|
||||
});
|
||||
|
||||
// Groups are tag-like and global. A group is shown under a category
|
||||
// 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 != sub.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({
|
||||
FlatItem::Type::Group,
|
||||
std::to_string(group.id),
|
||||
group.name,
|
||||
1, true, false, false,
|
||||
});
|
||||
for (const auto* p : members) {
|
||||
items_.push_back({
|
||||
FlatItem::Type::Proclamation,
|
||||
p.id,
|
||||
p.title,
|
||||
2, false, p.selected, false,
|
||||
p->id,
|
||||
p->title,
|
||||
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_) {
|
||||
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({
|
||||
FlatItem::Type::Proclamation,
|
||||
p.id,
|
||||
@@ -235,29 +249,29 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree walk: track the currently-open root and sub category (items_ is
|
||||
// already ordered root -> sub -> proclamation, so each category dominates
|
||||
// the items until the next category of equal or shallower depth).
|
||||
int currentRoot = -1; // items_ index of the current root category
|
||||
int currentSub = -1; // items_ index of the current sub-category
|
||||
// Tree walk: track the currently-open category and group (items_ is
|
||||
// already ordered category -> group -> proclamation, so each category
|
||||
// dominates the items until the next category).
|
||||
int currentCategory = -1; // items_ index of the current category
|
||||
int currentGroup = -1; // items_ index of the current group
|
||||
|
||||
for (size_t i = 0; i < items_.size(); ++i) {
|
||||
const FlatItem& item = items_[i];
|
||||
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
currentRoot = static_cast<int>(i);
|
||||
currentSub = -1;
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
currentCategory = static_cast<int>(i);
|
||||
currentGroup = -1;
|
||||
visible_.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type == FlatItem::Type::SubCategory) {
|
||||
bool rootOpen = currentRoot >= 0 && items_[currentRoot].expanded;
|
||||
if (rootOpen) {
|
||||
currentSub = static_cast<int>(i);
|
||||
if (item.type == FlatItem::Type::Group) {
|
||||
bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded;
|
||||
if (categoryOpen) {
|
||||
currentGroup = static_cast<int>(i);
|
||||
visible_.push_back(i);
|
||||
} else {
|
||||
currentSub = -1;
|
||||
currentGroup = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -266,9 +280,10 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
bool parentVisible = false;
|
||||
if (item.depth == 0) {
|
||||
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) ||
|
||||
(item.depth == 2 && currentSub >= 0);
|
||||
(item.depth == 2 && currentGroup >= 0 &&
|
||||
items_[currentGroup].expanded);
|
||||
}
|
||||
if (parentVisible) visible_.push_back(i);
|
||||
}
|
||||
@@ -283,7 +298,7 @@ void ProclamationOptionsPage::toggleItem()
|
||||
if (visible_.empty()) return;
|
||||
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;
|
||||
rebuildVisible();
|
||||
return;
|
||||
@@ -388,7 +403,7 @@ void ProclamationOptionsPage::collapseCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
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) {
|
||||
item.expanded = false;
|
||||
rebuildVisible();
|
||||
@@ -400,7 +415,7 @@ void ProclamationOptionsPage::expandCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
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) {
|
||||
item.expanded = true;
|
||||
rebuildVisible();
|
||||
@@ -417,8 +432,8 @@ void ProclamationOptionsPage::moveToParent()
|
||||
int start = static_cast<int>(visible_[highlightIndex_]);
|
||||
for (int i = start - 1; i >= 0; --i) {
|
||||
const FlatItem& candidate = items_[i];
|
||||
if ((candidate.type == FlatItem::Type::RootCategory ||
|
||||
candidate.type == FlatItem::Type::SubCategory) &&
|
||||
if ((candidate.type == FlatItem::Type::Category ||
|
||||
candidate.type == FlatItem::Type::Group) &&
|
||||
candidate.depth == item.depth - 1) {
|
||||
for (size_t vi = 0; vi < visible_.size(); ++vi) {
|
||||
if (visible_[vi] == static_cast<size_t>(i)) {
|
||||
@@ -526,7 +541,8 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
|
||||
if (searchActive_) return;
|
||||
if (!visible_.empty()) {
|
||||
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();
|
||||
} else {
|
||||
moveToParent();
|
||||
@@ -567,6 +583,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
SDL_Color white = {255, 255, 255, 255};
|
||||
SDL_Color gray = {170, 170, 170, 255};
|
||||
SDL_Color categoryBlue = {100, 180, 255, 255};
|
||||
SDL_Color groupBlue = {160, 210, 255, 255};
|
||||
SDL_Color selectedOrange = {255, 170, 60, 255};
|
||||
|
||||
float titleY = 50.0f;
|
||||
@@ -703,7 +720,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float maxTotalWidth = 0.0f;
|
||||
for (const auto& item : items_) {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
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;
|
||||
}
|
||||
int w = 0, h = 0;
|
||||
@@ -734,13 +751,15 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float y = listTop + rowIndex * rowPitch;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
SDL_Color color = white;
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::Category) {
|
||||
color = categoryBlue;
|
||||
} else if (item.type == FlatItem::Type::Group) {
|
||||
color = groupBlue;
|
||||
} else if (item.selected || item.isDefault) {
|
||||
color = selectedOrange;
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ public:
|
||||
|
||||
private:
|
||||
struct FlatItem {
|
||||
enum class Type { RootCategory, SubCategory, Proclamation };
|
||||
enum class Type { Category, Group, 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
|
||||
int depth = 0; // 0 = root category / default, 1 = sub-category, 2 = proclamation
|
||||
bool expanded = false; // categories only
|
||||
int depth = 0; // 0 = category / default, 1 = group, 2 = proclamation
|
||||
bool expanded = false; // categories and groups only
|
||||
bool selected = false; // proclamations only
|
||||
bool isDefault = false; // the always-on top-level proclamation
|
||||
};
|
||||
@@ -67,6 +67,7 @@ private:
|
||||
AppState* state_ = nullptr;
|
||||
std::vector<radiantwave::db::ProclamationRecord> proclamations_;
|
||||
std::vector<radiantwave::db::ProclamationCategoryRecord> categories_;
|
||||
std::vector<radiantwave::db::ProclamationGroupRecord> groups_;
|
||||
std::vector<FlatItem> items_;
|
||||
std::vector<size_t> visible_; // indices into items_, in display order
|
||||
std::vector<size_t> searchResults_; // indices into proclamations_, search mode
|
||||
|
||||
Reference in New Issue
Block a user