feat: initial scaffold of kanban_cpp v2 (issue 0130)
Frontend C++ ImGui (main.cpp + 4 paneles) + backend Go (HTTP + SQLite + fsnotify + SSE). Reusa parse/scan/watch funcs del registry (issue 0130a).
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
#include "data.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
#include "core/http_request.h"
|
||||
#include "core/sse_client.h"
|
||||
#include "core/logger.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace kanban {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
State& state() {
|
||||
static State s;
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::unique_ptr<fn_sse::Client> g_sse;
|
||||
|
||||
static std::vector<std::string> j_arr(const json& j, const char* key) {
|
||||
std::vector<std::string> out;
|
||||
if (!j.contains(key) || !j[key].is_array()) return out;
|
||||
for (const auto& v : j[key]) {
|
||||
if (v.is_string()) out.push_back(v.get<std::string>());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string j_str(const json& j, const char* key) {
|
||||
if (!j.contains(key) || !j[key].is_string()) return "";
|
||||
return j[key].get<std::string>();
|
||||
}
|
||||
|
||||
static Issue parse_issue(const json& j) {
|
||||
Issue i;
|
||||
i.id = j_str(j, "id");
|
||||
i.title = j_str(j, "title");
|
||||
i.status = j_str(j, "status");
|
||||
i.type = j_str(j, "type");
|
||||
i.scope = j_str(j, "scope");
|
||||
i.priority = j_str(j, "priority");
|
||||
i.domain = j_arr(j, "domain");
|
||||
i.tags = j_arr(j, "tags");
|
||||
i.depends = j_arr(j, "depends");
|
||||
i.blocks = j_arr(j, "blocks");
|
||||
i.related = j_arr(j, "related");
|
||||
i.flow = j_str(j, "flow");
|
||||
i.file_path = j_str(j, "file_path");
|
||||
if (j.contains("completed") && j["completed"].is_boolean())
|
||||
i.completed = j["completed"].get<bool>();
|
||||
i.body = j_str(j, "body");
|
||||
return i;
|
||||
}
|
||||
|
||||
static Flow parse_flow(const json& j) {
|
||||
Flow f;
|
||||
f.id = j_str(j, "id");
|
||||
f.title = j_str(j, "title");
|
||||
f.status = j_str(j, "status");
|
||||
f.kind = j_str(j, "kind");
|
||||
f.tags = j_arr(j, "tags");
|
||||
f.file_path = j_str(j, "file_path");
|
||||
f.body = j_str(j, "body");
|
||||
return f;
|
||||
}
|
||||
|
||||
static fn_http::Response http_get(const std::string& path) {
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = state().backend_url + path;
|
||||
req.timeout_ms = 5000;
|
||||
return fn_http::request(req);
|
||||
}
|
||||
|
||||
static fn_http::Response http_patch_json(const std::string& path, const std::string& body) {
|
||||
fn_http::Request req;
|
||||
req.method = "PATCH";
|
||||
req.url = state().backend_url + path;
|
||||
req.timeout_ms = 5000;
|
||||
req.headers.push_back({"Content-Type", "application/json"});
|
||||
req.body = body;
|
||||
return fn_http::request(req);
|
||||
}
|
||||
|
||||
bool refresh_issues() {
|
||||
auto resp = http_get("/api/issues");
|
||||
if (resp.status != 200) {
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().last_error = "issues fetch: " + std::to_string(resp.status) + " " + resp.error;
|
||||
return false;
|
||||
}
|
||||
auto j = json::parse(resp.body, nullptr, false);
|
||||
if (j.is_discarded() || !j.is_array()) {
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().last_error = "issues: invalid JSON";
|
||||
return false;
|
||||
}
|
||||
std::vector<Issue> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& it : j) out.push_back(parse_issue(it));
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().issues = std::move(out);
|
||||
state().last_refresh_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
state().last_error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool refresh_flows() {
|
||||
auto resp = http_get("/api/flows");
|
||||
if (resp.status != 200) {
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().last_error = "flows fetch: " + std::to_string(resp.status);
|
||||
return false;
|
||||
}
|
||||
auto j = json::parse(resp.body, nullptr, false);
|
||||
if (j.is_discarded() || !j.is_array()) return false;
|
||||
std::vector<Flow> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& it : j) out.push_back(parse_flow(it));
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().flows = std::move(out);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool refresh_meta() {
|
||||
auto resp = http_get("/api/meta");
|
||||
if (resp.status != 200) return false;
|
||||
auto j = json::parse(resp.body, nullptr, false);
|
||||
if (j.is_discarded()) return false;
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().meta.statuses = j_arr(j, "statuses");
|
||||
state().meta.priorities = j_arr(j, "priorities");
|
||||
state().meta.scopes = j_arr(j, "scopes");
|
||||
state().meta.types = j_arr(j, "types");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool refresh_issue_detail(const std::string& id) {
|
||||
auto resp = http_get("/api/issues/" + id);
|
||||
if (resp.status != 200) return false;
|
||||
auto j = json::parse(resp.body, nullptr, false);
|
||||
if (j.is_discarded()) return false;
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().selected_issue_id = id;
|
||||
state().selected_issue_detail = parse_issue(j);
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::string json_escape(const std::string& s) {
|
||||
json j = s;
|
||||
return j.dump();
|
||||
}
|
||||
|
||||
bool patch_issue_status(const std::string& id, const std::string& new_status) {
|
||||
std::string body = "{\"status\":" + json_escape(new_status) + "}";
|
||||
auto resp = http_patch_json("/api/issues/" + id, body);
|
||||
if (resp.status != 200) {
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().last_error = "PATCH status " + id + ": " + std::to_string(resp.status);
|
||||
return false;
|
||||
}
|
||||
// Update local cache without full refresh.
|
||||
auto j = json::parse(resp.body, nullptr, false);
|
||||
if (!j.is_discarded()) {
|
||||
Issue updated = parse_issue(j);
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
for (auto& i : state().issues) {
|
||||
if (i.id == id) {
|
||||
i = updated;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool patch_issue_fields(const std::string& id, const std::string& json_partial) {
|
||||
auto resp = http_patch_json("/api/issues/" + id, json_partial);
|
||||
if (resp.status != 200) {
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().last_error = "PATCH " + id + ": " + std::to_string(resp.status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void start_sse() {
|
||||
if (g_sse) return;
|
||||
fn_sse::Config cfg;
|
||||
cfg.url = state().backend_url + "/api/sse";
|
||||
g_sse = std::make_unique<fn_sse::Client>();
|
||||
g_sse->start(
|
||||
cfg,
|
||||
[](const fn_sse::Event& /*ev*/) {
|
||||
std::lock_guard<std::mutex> g(state().mu);
|
||||
state().last_refresh_ns = 0;
|
||||
},
|
||||
nullptr);
|
||||
}
|
||||
|
||||
void stop_sse() {
|
||||
if (g_sse) {
|
||||
g_sse->stop();
|
||||
g_sse.reset();
|
||||
}
|
||||
}
|
||||
|
||||
static bool contains(const std::set<std::string>& s, const std::string& v) {
|
||||
return s.find(v) != s.end();
|
||||
}
|
||||
|
||||
bool passes_filters(const Issue& iss) {
|
||||
const auto& f = state().filters;
|
||||
if (!f.include_completed && iss.completed) return false;
|
||||
if (!f.priorities.empty() && !contains(f.priorities, iss.priority)) return false;
|
||||
if (!f.scopes.empty() && !contains(f.scopes, iss.scope)) return false;
|
||||
if (!f.domains.empty()) {
|
||||
bool match = false;
|
||||
for (const auto& d : iss.domain) {
|
||||
if (contains(f.domains, d)) {
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) return false;
|
||||
}
|
||||
if (!f.tags.empty()) {
|
||||
bool match = false;
|
||||
for (const auto& t : iss.tags) {
|
||||
if (contains(f.tags, t)) {
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::string> collect_domains() {
|
||||
std::set<std::string> uniq;
|
||||
for (const auto& i : state().issues) {
|
||||
for (const auto& d : i.domain) uniq.insert(d);
|
||||
}
|
||||
return {uniq.begin(), uniq.end()};
|
||||
}
|
||||
|
||||
std::vector<std::string> collect_tags() {
|
||||
std::set<std::string> uniq;
|
||||
for (const auto& i : state().issues) {
|
||||
for (const auto& t : i.tags) uniq.insert(t);
|
||||
}
|
||||
return {uniq.begin(), uniq.end()};
|
||||
}
|
||||
|
||||
} // namespace kanban
|
||||
Reference in New Issue
Block a user