diff --git a/AGENTS.md b/AGENTS.md index b3b1b2e..4ce1316 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,14 @@ suite and need the `RWT_LIVE_*` secrets to pass. - `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires `onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read 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. --- diff --git a/README.md b/README.md index a5471c0..5beb1c9 100644 --- a/README.md +++ b/README.md @@ -484,8 +484,9 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode). | ------- | --------- | | app_config | Key-value store: license, email, audio device, volume | | logs | Application logs | -| proclamations | Synced proclamation text | -| proclamation_categories | Category metadata | +| proclamations | Synced proclamation text (category_id + group_id) | +| proclamation_categories | Flat category metadata | +| proclamation_groups | Tag-like groups that organize proclamations within a category | | solfeggio_frequencies | 9 frequency presets | | audio_files | Downloaded audio files | diff --git a/src/db/database_manager.cpp b/src/db/database_manager.cpp index 35bd700..0b6b923 100644 --- a/src/db/database_manager.cpp +++ b/src/db/database_manager.cpp @@ -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 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& records) { + std::lock_guard 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& 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 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 DatabaseManager::getAllCategories() { std::vector 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 DatabaseManager::getAllCategories() { ProclamationCategoryRecord rec; rec.id = sqlite3_column_int(stmt, 0); rec.name = reinterpret_cast(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 DatabaseManager::getAllGroups() { + std::lock_guard lock(mutex_); + std::vector 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(sqlite3_column_text(stmt, 1)); + rec.sort_order = sqlite3_column_int(stmt, 2); result.push_back(rec); } diff --git a/src/db/database_manager.h b/src/db/database_manager.h index 616cb64..21c5a03 100644 --- a/src/db/database_manager.h +++ b/src/db/database_manager.h @@ -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& records); + // Full sync: replaces all groups with the given set. + bool replaceGroups(const std::vector& 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 getAllCategories(); + // Fetch all groups ordered by sort_order then id. + std::vector 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, diff --git a/src/network/api_client.h b/src/network/api_client.h index bb68d19..0f71c9c 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -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"; diff --git a/src/network/proclamation_sync.cpp b/src/network/proclamation_sync.cpp index e0b6452..1eeac31 100644 --- a/src/network/proclamation_sync.cpp +++ b/src/network/proclamation_sync.cpp @@ -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(entry.numberField("categoryId")); + rec.group_id = static_cast(entry.numberField("groupId")); rec.solfeggio_frequency_id = static_cast(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(entry.numberField("parentId")); + rec.sort_order = static_cast(entry.numberField("sortOrder")); + out.push_back(std::move(rec)); + } + return true; +} + +bool parseGroupsJson(const std::string& body, + std::vector& 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(entry.numberField("id")); + if (rec.id == 0) continue; + + rec.name = entry.stringField("name"); rec.sort_order = static_cast(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 proclamations; std::vector categories; + std::vector 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; } diff --git a/src/network/proclamation_sync.h b/src/network/proclamation_sync.h index 1c47612..706a6a0 100644 --- a/src/network/proclamation_sync.h +++ b/src/network/proclamation_sync.h @@ -15,9 +15,13 @@ bool parseProclamationsJson(const std::string& body, bool parseCategoriesJson(const std::string& body, std::vector& 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& 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); diff --git a/src/proclamation_options_page.cpp b/src/proclamation_options_page.cpp index eb67da6..f6e497a 100644 --- a/src/proclamation_options_page.cpp +++ b/src/proclamation_options_page.cpp @@ -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 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(i); - currentSub = -1; + if (item.type == FlatItem::Type::Category) { + currentCategory = static_cast(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(i); + if (item.type == FlatItem::Type::Group) { + bool categoryOpen = currentCategory >= 0 && items_[currentCategory].expanded; + if (categoryOpen) { + currentGroup = static_cast(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(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(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(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; } diff --git a/src/proclamation_options_page.h b/src/proclamation_options_page.h index f71f139..f69e257 100644 --- a/src/proclamation_options_page.h +++ b/src/proclamation_options_page.h @@ -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 proclamations_; std::vector categories_; + std::vector groups_; std::vector items_; std::vector visible_; // indices into items_, in display order std::vector searchResults_; // indices into proclamations_, search mode diff --git a/tests/test_api_client.cpp b/tests/test_api_client.cpp index 5684e21..39e2bd0 100644 --- a/tests/test_api_client.cpp +++ b/tests/test_api_client.cpp @@ -142,16 +142,25 @@ TEST(ApiClientTest, ParseProclamationsToleratesLegacyLevelField) { EXPECT_EQ(out[1].category_id, 32); } -TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) { - const std::string body = - R"([{"id":4,"name":"Spiritual","sortOrder":0,"parentId":null}, - {"id":5,"name":"General","sortOrder":0,"parentId":4}])"; +TEST(ApiClientTest, ParseFlatCategoriesAndGroups) { + const std::string categoriesBody = + R"([{"id":4,"name":"Spiritual","sortOrder":0}, + {"id":5,"name":"General","sortOrder":1}])"; - std::vector out; - EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(body, out)); - ASSERT_EQ(out.size(), 2u); - EXPECT_EQ(out[0].parent_id, 0); // null parentId maps to 0 (root) - EXPECT_EQ(out[1].parent_id, 4); + std::vector categories; + EXPECT_TRUE(radiantwave::sync::parseCategoriesJson(categoriesBody, categories)); + ASSERT_EQ(categories.size(), 2u); + EXPECT_EQ(categories[0].id, 4); + EXPECT_EQ(categories[1].id, 5); + + const std::string groupsBody = + R"([{"id":9,"name":"Prayers","sortOrder":0}])"; + + std::vector 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) ─────────────────── @@ -194,7 +203,15 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) { std::vector 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 groups; + EXPECT_TRUE(radiantwave::sync::parseGroupsJson(groupsResponse.body, groups)); + EXPECT_FALSE(groups.empty()); } // ─── Live: POST /license error paths (no credentials required) ───────── diff --git a/tests/test_db.cpp b/tests/test_db.cpp index da93694..f58595b 100644 --- a/tests/test_db.cpp +++ b/tests/test_db.cpp @@ -185,23 +185,119 @@ TEST(DatabaseManagerTest, ReplaceAndReadCategories) { DbFixture fx("test_rw_db_cats.db"); std::vector cats; - cats.push_back({3, "Root", 0, 1}); - cats.push_back({7, "Child", 3, 2}); + cats.push_back({3, "Spiritual", 1}); + cats.push_back({7, "Physical", 2}); ASSERT_TRUE(fx.db.replaceCategories(cats)); auto all = fx.db.getAllCategories(); ASSERT_EQ(all.size(), 2u); 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].parent_id, 3); + EXPECT_EQ(all[1].name, "Physical"); + EXPECT_EQ(all[1].sort_order, 2); // Replacing wipes previous categories. ASSERT_TRUE(fx.db.replaceCategories({})); EXPECT_EQ(fx.db.getAllCategories().size(), 0u); } +TEST(DatabaseManagerTest, ReplaceAndReadGroups) { + DbFixture fx("test_rw_db_groups.db"); + + std::vector 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) { DbFixture fx("test_rw_db_audio.db"); diff --git a/tests/test_proclamation_sync.cpp b/tests/test_proclamation_sync.cpp index 6c691b3..2c8ff3e 100644 --- a/tests/test_proclamation_sync.cpp +++ b/tests/test_proclamation_sync.cpp @@ -6,8 +6,8 @@ using namespace radiantwave::sync; TEST(ProclamationSyncTest, ParseProclamationsJson) { const std::string body = - R"([{"id":"p1","name":"Peace","content":"

I am peace.

","categoryId":3,"solfeggioFrequencyId":2}, - {"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"solfeggioFrequencyId":0}, + R"([{"id":"p1","name":"Peace","content":"

I am peace.

","categoryId":3,"groupId":7,"solfeggioFrequencyId":2}, + {"id":"p2","name":"Joy & Calm","content":"text","categoryId":0,"groupId":0,"solfeggioFrequencyId":0}, {"notAnObject":true}])"; std::vector out; @@ -18,12 +18,14 @@ TEST(ProclamationSyncTest, ParseProclamationsJson) { EXPECT_EQ(out[0].title, "Peace"); EXPECT_EQ(out[0].content, "I am peace."); 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[1].id, "p2"); EXPECT_EQ(out[1].title, "Joy & Calm"); EXPECT_EQ(out[1].content, "text"); EXPECT_EQ(out[1].category_id, 0); + EXPECT_EQ(out[1].group_id, 0); EXPECT_EQ(out[1].solfeggio_frequency_id, 0); } @@ -38,9 +40,10 @@ TEST(ProclamationSyncTest, ParseIgnoresLegacySubscriptionLevelField) { } TEST(ProclamationSyncTest, ParseCategoriesJson) { + // Categories are flat now; a legacy parentId field is simply ignored. const std::string body = - R"([{"id":3,"name":"Spiritual","parentId":0,"sortOrder":1}, - {"id":7,"name":"Inner","parentId":3,"sortOrder":2}])"; + R"([{"id":3,"name":"Spiritual","sortOrder":1}, + {"id":7,"name":"Physical","parentId":3,"sortOrder":2}])"; std::vector out; EXPECT_TRUE(parseCategoriesJson(body, out)); @@ -48,18 +51,35 @@ TEST(ProclamationSyncTest, ParseCategoriesJson) { EXPECT_EQ(out[0].id, 3); EXPECT_EQ(out[0].name, "Spiritual"); - EXPECT_EQ(out[0].parent_id, 0); EXPECT_EQ(out[0].sort_order, 1); EXPECT_EQ(out[1].id, 7); - EXPECT_EQ(out[1].name, "Inner"); - EXPECT_EQ(out[1].parent_id, 3); + EXPECT_EQ(out[1].name, "Physical"); 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 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) { std::vector procs; std::vector cats; + std::vector groups; EXPECT_FALSE(parseProclamationsJson("not json", procs)); 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. EXPECT_TRUE(parseCategoriesJson(R"([{"id":"x"}])", cats)); 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) { @@ -78,9 +102,14 @@ TEST(ProclamationSyncTest, ParseSkipsMissingIdAndZeroCategory) { EXPECT_EQ(procs[0].id, "ok"); std::vector 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); EXPECT_EQ(cats[0].id, 9); + + std::vector 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) {