Adds api_client support for GET /licenses/{key}/schedule with If-None-Match
and target-payload parsing (version/type/daily/weekly + legacy blocks), an
atomic on-disk schedule cache, a pure schedule engine (half-open blocks,
midnight self-wrap for daily, Sunday->Monday spillover for weekly, invalid
blocks skipped with a warning), and a background ScheduleManager that loads
the cache offline and pulls on boot and every 5 minutes. Adds engine/cache
tests against the shared docs/fixtures, including 304, corrupt cache, and
offline cases.
159 lines
4.5 KiB
C++
159 lines
4.5 KiB
C++
#include "network/schedule_cache.h"
|
|
#include "network/json.h"
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <fstream>
|
|
#include <pwd.h>
|
|
#include <sstream>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
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
|