Move Proclamations From Sub-Categories To Groups
build-dev / nix-build (push) Successful in 32s

The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.

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

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

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

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

Docs: README database table and AGENTS notes updated for groups.
This commit is contained in:
NeilClack
2026-09-10 19:49:47 -05:00
parent 2b1040d3db
commit 9fead922c4
12 changed files with 460 additions and 98 deletions
+153 -18
View File
@@ -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);
}
+18 -1
View File
@@ -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,
+1
View File
@@ -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";
+36 -2
View File
@@ -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;
}
+7 -3
View File
@@ -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);
+65 -46
View File
@@ -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;
}
+5 -4
View File
@@ -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