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.
261 lines
9.1 KiB
C++
261 lines
9.1 KiB
C++
#include "network/proclamation_sync.h"
|
|
#include "network/api_client.h"
|
|
#include "network/json.h"
|
|
|
|
#include <string>
|
|
|
|
namespace radiantwave::sync {
|
|
|
|
namespace {
|
|
|
|
// ─── HTML stripping ────────────────────────────────────────────────────
|
|
|
|
std::string replaceAll(std::string text, const std::string& from, const std::string& to) {
|
|
if (from.empty()) return text;
|
|
size_t pos = 0;
|
|
while ((pos = text.find(from, pos)) != std::string::npos) {
|
|
text.replace(pos, from.size(), to);
|
|
pos += to.size();
|
|
}
|
|
return text;
|
|
}
|
|
|
|
std::string stripHtml(const std::string& in) {
|
|
std::string out;
|
|
out.reserve(in.size());
|
|
bool inTag = false;
|
|
for (char c : in) {
|
|
if (c == '<') {
|
|
inTag = true;
|
|
} else if (c == '>') {
|
|
inTag = false;
|
|
} else if (!inTag) {
|
|
out += c;
|
|
}
|
|
}
|
|
|
|
out = replaceAll(std::move(out), " ", " ");
|
|
out = replaceAll(std::move(out), "&", "&");
|
|
out = replaceAll(std::move(out), "<", "<");
|
|
out = replaceAll(std::move(out), ">", ">");
|
|
out = replaceAll(std::move(out), """, "\"");
|
|
out = replaceAll(std::move(out), "'", "'");
|
|
out = replaceAll(std::move(out), "'", "'");
|
|
return out;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// ─── Public API ────────────────────────────────────────────────────────
|
|
|
|
bool parseProclamationsJson(const std::string& body,
|
|
std::vector<radiantwave::db::ProclamationRecord>& 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::ProclamationRecord rec;
|
|
rec.id = entry.stringField("id");
|
|
if (rec.id.empty()) continue;
|
|
|
|
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));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool parseCategoriesJson(const std::string& body,
|
|
std::vector<radiantwave::db::ProclamationCategoryRecord>& 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::ProclamationCategoryRecord 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));
|
|
}
|
|
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));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
|
const std::string& defaultProclamationId) {
|
|
// TODO(portal #4/#5): once the portal exposes color palettes, sync them
|
|
// alongside proclamations here (syncColorPalettesFromApi or
|
|
// syncProclamationPalettesFromApi) so every caller gets palettes in one step.
|
|
api::HttpResponse proclamationsResponse =
|
|
api::httpGet(api::PATH_PROCLAMATIONS);
|
|
if (!proclamationsResponse.transportOk ||
|
|
proclamationsResponse.status < 200 || proclamationsResponse.status >= 300) {
|
|
return false;
|
|
}
|
|
|
|
api::HttpResponse categoriesResponse =
|
|
api::httpGet(api::PATH_CATEGORIES);
|
|
if (!categoriesResponse.transportOk ||
|
|
categoriesResponse.status < 200 || categoriesResponse.status >= 300) {
|
|
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) ||
|
|
!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;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ─── Color palettes (readiness code; not wired into the app flow) ─────
|
|
|
|
bool parseProclamationPalettesJson(const std::string& body,
|
|
std::vector<radiantwave::db::PaletteRecord>& out) {
|
|
json::JsonValue root;
|
|
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
|
|
return false;
|
|
}
|
|
|
|
auto parseHex = [](const std::string& hex) -> radiantwave::db::PaletteColor {
|
|
radiantwave::db::PaletteColor c;
|
|
auto nibble = [&hex](size_t i) -> int {
|
|
char ch = hex.size() > i ? hex[i] : '0';
|
|
if (ch >= '0' && ch <= '9') return ch - '0';
|
|
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
|
|
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
|
|
return 0;
|
|
};
|
|
// Accept "#RRGGBB" or "RRGGBB".
|
|
size_t base = (!hex.empty() && hex[0] == '#') ? 1 : 0;
|
|
c.r = static_cast<uint8_t>(nibble(base) * 16 + nibble(base + 1));
|
|
c.g = static_cast<uint8_t>(nibble(base + 2) * 16 + nibble(base + 3));
|
|
c.b = static_cast<uint8_t>(nibble(base + 4) * 16 + nibble(base + 5));
|
|
return c;
|
|
};
|
|
|
|
out.clear();
|
|
for (const auto& entry : root.array) {
|
|
if (entry.type != json::JsonValue::Type::Object) continue;
|
|
|
|
int freqId = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
|
|
if (freqId == 0) continue;
|
|
|
|
const json::JsonValue* colors = entry.find("colorPalette");
|
|
if (!colors || colors->type != json::JsonValue::Type::Array || colors->array.empty()) {
|
|
continue;
|
|
}
|
|
|
|
// When several proclamations share a frequency, the first wins.
|
|
bool exists = false;
|
|
for (const auto& existing : out) {
|
|
if (existing.solfeggio_frequency_id == freqId) {
|
|
exists = true;
|
|
break;
|
|
}
|
|
}
|
|
if (exists) continue;
|
|
|
|
radiantwave::db::PaletteRecord rec;
|
|
rec.solfeggio_frequency_id = freqId;
|
|
for (const auto& color : colors->array) {
|
|
if (color.type == json::JsonValue::Type::String) {
|
|
rec.colors.push_back(parseHex(color.str));
|
|
}
|
|
}
|
|
out.push_back(std::move(rec));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db) {
|
|
std::vector<api::ColorPalette> palettes;
|
|
if (!api::fetchColorPalettes(palettes)) {
|
|
return false;
|
|
}
|
|
|
|
std::vector<radiantwave::db::PaletteRecord> records;
|
|
records.reserve(palettes.size());
|
|
for (const auto& p : palettes) {
|
|
radiantwave::db::PaletteRecord rec;
|
|
rec.solfeggio_frequency_id = p.solfeggio_frequency_id;
|
|
rec.colors.reserve(p.colors.size());
|
|
for (const auto& c : p.colors) {
|
|
rec.colors.push_back({c.r, c.g, c.b, c.a});
|
|
}
|
|
records.push_back(std::move(rec));
|
|
}
|
|
return db.replaceColorPalettes(records);
|
|
}
|
|
|
|
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db) {
|
|
api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS);
|
|
if (!response.transportOk || response.status < 200 || response.status >= 300) {
|
|
return false;
|
|
}
|
|
|
|
std::vector<radiantwave::db::PaletteRecord> records;
|
|
if (!parseProclamationPalettesJson(response.body, records)) {
|
|
return false;
|
|
}
|
|
return db.replaceColorPalettes(records);
|
|
}
|
|
|
|
} // namespace radiantwave::sync
|