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,
|
||||
|
||||
Reference in New Issue
Block a user