#include "network/schedule_cache.h" #include "network/json.h" #include #include #include #include #include #include #include namespace radiantwave::sync::schedule_cache { namespace { std::string homeDir() { const char* home = std::getenv("HOME"); if (!home) { struct passwd* pw = getpwuid(getuid()); if (pw) home = pw->pw_dir; } return home ? std::string(home) : std::string("/tmp"); } void ensureDir(const std::string& dir) { // Create the directory and any missing parents (e.g. ~/.local/radiantwave // when ~/.local does not exist yet). std::string path; for (size_t i = 0; i < dir.size(); ++i) { path += dir[i]; if (dir[i] == '/' || i == dir.size() - 1) { if (path.size() > 1) { mkdir(path.c_str(), 0755); } } } } // Minimal JSON string escaping for the cache writer. std::string escape(const std::string& in) { std::string out; out.reserve(in.size() + 2); for (char c : in) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: out += c; } } return out; } std::string blockJson(const api::TargetBlock& block, bool includeDay) { std::ostringstream os; os << "{\"id\":\"" << escape(block.id) << "\""; if (includeDay) { os << ",\"day\":\"" << escape(block.day) << "\""; } os << ",\"startMinutes\":" << block.start_minutes << ",\"endMinutes\":" << block.end_minutes << ",\"overnight\":" << (block.overnight ? "true" : "false") << ",\"durationMinutes\":" << block.duration_minutes << ",\"dynamicAudio\":" << (block.dynamic_audio ? "true" : "false") << ",\"proclamationIds\":["; for (size_t i = 0; i < block.proclamation_ids.size(); ++i) { if (i > 0) os << ","; os << "\"" << escape(block.proclamation_ids[i]) << "\""; } os << "]}"; return os.str(); } std::string serialize(const api::SchedulePayload& payload, const std::string& etag) { std::ostringstream os; os << "{\"version\":\"" << escape(payload.version) << "\",\"type\":\"" << escape(payload.type) << "\",\"etag\":\"" << escape(etag) << "\",\"daily\":["; for (size_t i = 0; i < payload.daily.size(); ++i) { if (i > 0) os << ","; os << blockJson(payload.daily[i], false); } os << "],\"weekly\":["; for (size_t i = 0; i < payload.weekly.size(); ++i) { if (i > 0) os << ","; os << blockJson(payload.weekly[i], true); } os << "]}"; return os.str(); } } // namespace std::string cachePath() { return homeDir() + "/.local/radiantwave/schedule_cache.json"; } bool write(const api::SchedulePayload& payload, const std::string& etag) { const std::string dir = homeDir() + "/.local/radiantwave"; ensureDir(dir); const std::string path = cachePath(); const std::string tmp = path + ".tmp"; { std::ofstream out(tmp, std::ios::binary | std::ios::trunc); if (!out.is_open()) return false; out << serialize(payload, etag); out.flush(); if (!out.good()) { out.close(); std::remove(tmp.c_str()); return false; } } // Atomic replace: a same-filesystem rename is atomic on POSIX. if (std::rename(tmp.c_str(), path.c_str()) != 0) { std::remove(tmp.c_str()); return false; } return true; } bool read(api::SchedulePayload& out, std::string& outEtag) { std::ifstream in(cachePath(), std::ios::binary); if (!in.is_open()) return false; std::ostringstream buffer; buffer << in.rdbuf(); const std::string text = buffer.str(); if (text.empty()) return false; // The cache stores the same version/type/daily/weekly shape as the API, // plus an "etag" field, so the API parser (which ignores unknown fields) // validates it directly. api::SchedulePayload parsed; if (!api::parseSchedulePayloadJson(text, parsed)) { return false; } radiantwave::json::JsonValue root; if (!radiantwave::json::parseDocument(text, root)) { return false; } outEtag = root.stringField("etag"); out = std::move(parsed); return true; } bool clear() { return std::remove(cachePath().c_str()) == 0 || true; } } // namespace radiantwave::sync::schedule_cache