#include "network/proclamation_sync.h" #include "db/db_keys.h" #include "network/api_client.h" #include "network/json.h" #include 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& 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(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)); } return true; } bool parseCategoriesJson(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::ProclamationCategoryRecord 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)); } 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)); } 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 proclamations; std::vector categories; std::vector 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; } // Best-effort: a schedule fetch/store failure must never fail the // proclamation sync or roll back the data just written. syncScheduleFromApi(db); return true; } bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db) { const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS); const std::string license_key = db.getKV(radiantwave::db::KV_LICENSE_KEY); if (email.empty() || license_key.empty()) { return false; } api::ScheduleResponse schedule; if (!api::fetchSchedule(email, license_key, schedule)) { return false; } std::vector records; records.reserve(schedule.blocks.size()); for (const auto& block : schedule.blocks) { radiantwave::db::ScheduleBlockRecord rec; rec.id = block.id; rec.start_minutes = block.start_minutes; rec.end_minutes = block.end_minutes; rec.overnight = block.overnight; rec.duration_minutes = block.duration_minutes; rec.dynamic_audio = block.dynamic_audio; rec.proclamation_ids = block.proclamation_ids; records.push_back(std::move(rec)); } return db.replaceScheduleBlocks(records); } // ─── Color palettes (readiness code; not wired into the app flow) ───── bool parseProclamationPalettesJson(const std::string& body, std::vector& 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(nibble(base) * 16 + nibble(base + 1)); c.g = static_cast(nibble(base + 2) * 16 + nibble(base + 3)); c.b = static_cast(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(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 palettes; if (!api::fetchColorPalettes(palettes)) { return false; } std::vector 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 records; if (!parseProclamationPalettesJson(response.body, records)) { return false; } return db.replaceColorPalettes(records); } } // namespace radiantwave::sync