This commit is contained in:
351
src/storage/dashboard_store.cpp
Normal file
351
src/storage/dashboard_store.cpp
Normal file
@@ -0,0 +1,351 @@
|
||||
#include "storage/dashboard_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
const char* kDefaultId = "dashboard_default";
|
||||
|
||||
nlohmann::json loadWidgets(sqlite3* db, const std::string& dashboard_id)
|
||||
{
|
||||
nlohmann::json widgets = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT id, type, title, mission_id, mission_group, config_json, sort_order "
|
||||
"FROM dashboard_widgets WHERE dashboard_id = ?1 ORDER BY sort_order, id",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return widgets;
|
||||
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
nlohmann::json w;
|
||||
w["id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
w["type"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
w["title"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
|
||||
w["mission_id"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
if (sqlite3_column_type(stmt, 4) != SQLITE_NULL)
|
||||
w["group"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
|
||||
const char* cfg = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 5));
|
||||
if (cfg)
|
||||
{
|
||||
try
|
||||
{
|
||||
const auto extra = nlohmann::json::parse(cfg);
|
||||
if (extra.is_object())
|
||||
{
|
||||
for (auto it = extra.begin(); it != extra.end(); ++it)
|
||||
{
|
||||
if (!w.contains(it.key()))
|
||||
w[it.key()] = it.value();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
widgets.push_back(w);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return widgets;
|
||||
}
|
||||
|
||||
nlohmann::json loadEditGroups(sqlite3* db, const std::string& dashboard_id)
|
||||
{
|
||||
nlohmann::json groups = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT group_id FROM dashboard_edit_groups WHERE dashboard_id = ?1 ORDER BY group_id",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return groups;
|
||||
sqlite3_bind_text(stmt, 1, dashboard_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
groups.push_back(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0)));
|
||||
sqlite3_finalize(stmt);
|
||||
return groups;
|
||||
}
|
||||
|
||||
std::optional<std::string> loadActiveDashboardId(sqlite3* db)
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db, "SELECT active_dashboard_id FROM dashboard_state WHERE id = 1", -1, &stmt, nullptr) !=
|
||||
SQLITE_OK)
|
||||
return std::nullopt;
|
||||
std::optional<std::string> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) != SQLITE_NULL)
|
||||
out = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DashboardStore::DashboardStore(Database& db) : db_(db)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
ensureDefaultsUnlocked();
|
||||
}
|
||||
|
||||
void DashboardStore::ensureDefaultsUnlocked()
|
||||
{
|
||||
sqlite3_stmt* count_stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "SELECT COUNT(*) FROM dashboards", -1, &count_stmt, nullptr) != SQLITE_OK)
|
||||
return;
|
||||
int count = 0;
|
||||
if (sqlite3_step(count_stmt) == SQLITE_ROW)
|
||||
count = sqlite3_column_int(count_stmt, 0);
|
||||
sqlite3_finalize(count_stmt);
|
||||
if (count > 0)
|
||||
return;
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
sqlite3_stmt* ins = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
|
||||
"created_at, updated_at) VALUES(?1,?2,?3,NULL,1,0,?4,?4)",
|
||||
-1,
|
||||
&ins,
|
||||
nullptr) != SQLITE_OK)
|
||||
return;
|
||||
sqlite3_bind_text(ins, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(ins, 2, "Default Dashboard", -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(ins, 3, "MiR", -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(ins, 4, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(ins);
|
||||
sqlite3_finalize(ins);
|
||||
|
||||
for (const char* gid :
|
||||
{"group_administrators", "group_distributors", "group_users"})
|
||||
{
|
||||
sqlite3_stmt* g = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT OR IGNORE INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
|
||||
-1,
|
||||
&g,
|
||||
nullptr) != SQLITE_OK)
|
||||
continue;
|
||||
sqlite3_bind_text(g, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||
sqlite3_bind_text(g, 2, gid, -1, SQLITE_STATIC);
|
||||
sqlite3_step(g);
|
||||
sqlite3_finalize(g);
|
||||
}
|
||||
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
|
||||
-1,
|
||||
&st,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(st, 1, kDefaultId, -1, SQLITE_STATIC);
|
||||
sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json DashboardStore::snapshot() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json dashboards = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, created_by, created_by_user, is_default FROM dashboards ORDER BY sort_order, name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return {{"dashboards", dashboards}, {"activeDashboardId", nullptr}};
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const std::string id = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
nlohmann::json d;
|
||||
d["id"] = id;
|
||||
d["name"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||||
d["createdBy"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2));
|
||||
if (sqlite3_column_type(stmt, 3) != SQLITE_NULL)
|
||||
d["createdByUser"] = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
|
||||
else
|
||||
d["createdByUser"] = nullptr;
|
||||
d["isDefault"] = sqlite3_column_int(stmt, 4) != 0;
|
||||
d["editGroups"] = loadEditGroups(db_.handle(), id);
|
||||
d["widgets"] = loadWidgets(db_.handle(), id);
|
||||
dashboards.push_back(d);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
std::string active_id = kDefaultId;
|
||||
if (auto active = loadActiveDashboardId(db_.handle()))
|
||||
active_id = *active;
|
||||
if (dashboards.empty())
|
||||
return {{"dashboards", nlohmann::json::array()}, {"activeDashboardId", nullptr}};
|
||||
return {{"dashboards", dashboards}, {"activeDashboardId", active_id}};
|
||||
}
|
||||
|
||||
bool DashboardStore::replace(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return false;
|
||||
}
|
||||
if (!payload.contains("dashboards") || !payload["dashboards"].is_array())
|
||||
{
|
||||
err = "dashboards array is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3* db = db_.handle();
|
||||
char* msg = nullptr;
|
||||
if (sqlite3_exec(db, "BEGIN IMMEDIATE", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : "begin failed";
|
||||
sqlite3_free(msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto rollback = [&]() {
|
||||
sqlite3_exec(db, "ROLLBACK", nullptr, nullptr, nullptr);
|
||||
};
|
||||
|
||||
if (sqlite3_exec(db, "DELETE FROM dashboard_widgets", nullptr, nullptr, &msg) != SQLITE_OK ||
|
||||
sqlite3_exec(db, "DELETE FROM dashboard_edit_groups", nullptr, nullptr, &msg) != SQLITE_OK ||
|
||||
sqlite3_exec(db, "DELETE FROM dashboards", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : "clear failed";
|
||||
sqlite3_free(msg);
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
int sort = 0;
|
||||
for (const auto& d : payload["dashboards"])
|
||||
{
|
||||
if (!d.is_object() || !d.contains("id"))
|
||||
continue;
|
||||
const std::string id = d.value("id", IdUtil::newId());
|
||||
sqlite3_stmt* ins = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO dashboards(id, name, created_by, created_by_user, is_default, sort_order, "
|
||||
"created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?7)",
|
||||
-1,
|
||||
&ins,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
rollback();
|
||||
err = "insert dashboard failed";
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(ins, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ins, 2, d.value("name", "Dashboard").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ins, 3, d.value("createdBy", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (d.contains("createdByUser") && !d["createdByUser"].is_null())
|
||||
sqlite3_bind_text(ins, 4, d["createdByUser"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(ins, 4);
|
||||
sqlite3_bind_int(ins, 5, d.value("isDefault", false) ? 1 : 0);
|
||||
sqlite3_bind_int(ins, 6, sort++);
|
||||
sqlite3_bind_text(ins, 7, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(ins);
|
||||
sqlite3_finalize(ins);
|
||||
|
||||
if (d.contains("editGroups") && d["editGroups"].is_array())
|
||||
{
|
||||
for (const auto& g : d["editGroups"])
|
||||
{
|
||||
if (!g.is_string())
|
||||
continue;
|
||||
sqlite3_stmt* gs = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO dashboard_edit_groups(dashboard_id, group_id) VALUES(?1,?2)",
|
||||
-1,
|
||||
&gs,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(gs, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(gs, 2, g.get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(gs);
|
||||
sqlite3_finalize(gs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("widgets") && d["widgets"].is_array())
|
||||
{
|
||||
int wsort = 0;
|
||||
for (const auto& w : d["widgets"])
|
||||
{
|
||||
if (!w.is_object())
|
||||
continue;
|
||||
nlohmann::json config = nlohmann::json::object();
|
||||
for (auto it = w.begin(); it != w.end(); ++it)
|
||||
{
|
||||
if (it.key() != "id" && it.key() != "type" && it.key() != "title" && it.key() != "mission_id" &&
|
||||
it.key() != "group")
|
||||
config[it.key()] = it.value();
|
||||
}
|
||||
sqlite3_stmt* ws = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO dashboard_widgets(id, dashboard_id, type, title, mission_id, mission_group, "
|
||||
"config_json, sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
-1,
|
||||
&ws,
|
||||
nullptr) != SQLITE_OK)
|
||||
continue;
|
||||
const std::string wid = w.value("id", IdUtil::newId());
|
||||
sqlite3_bind_text(ws, 1, wid.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ws, 2, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ws, 3, w.value("type", "unknown").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ws, 4, w.value("title", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (w.contains("mission_id") && w["mission_id"].is_string())
|
||||
sqlite3_bind_text(ws, 5, w["mission_id"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(ws, 5);
|
||||
if (w.contains("group") && w["group"].is_string())
|
||||
sqlite3_bind_text(ws, 6, w["group"].get<std::string>().c_str(), -1, SQLITE_TRANSIENT);
|
||||
else
|
||||
sqlite3_bind_null(ws, 6);
|
||||
const std::string cfg = config.dump();
|
||||
sqlite3_bind_text(ws, 7, cfg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(ws, 8, wsort++);
|
||||
sqlite3_step(ws);
|
||||
sqlite3_finalize(ws);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const std::string active = payload.value("activeDashboardId", kDefaultId);
|
||||
sqlite3_stmt* ast = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT OR REPLACE INTO dashboard_state(id, active_dashboard_id) VALUES(1, ?1)",
|
||||
-1,
|
||||
&ast,
|
||||
nullptr) == SQLITE_OK)
|
||||
{
|
||||
sqlite3_bind_text(ast, 1, active.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(ast);
|
||||
sqlite3_finalize(ast);
|
||||
}
|
||||
|
||||
if (sqlite3_exec(db, "COMMIT", nullptr, nullptr, &msg) != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : "commit failed";
|
||||
sqlite3_free(msg);
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
28
src/storage/dashboard_store.hpp
Normal file
28
src/storage/dashboard_store.hpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class DashboardStore
|
||||
{
|
||||
public:
|
||||
explicit DashboardStore(Database& db);
|
||||
|
||||
nlohmann::json snapshot() const;
|
||||
bool replace(const nlohmann::json& payload, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
|
||||
void ensureDefaultsUnlocked();
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
412
src/storage/database.cpp
Normal file
412
src/storage/database.cpp
Normal file
@@ -0,0 +1,412 @@
|
||||
#include "storage/database.hpp"
|
||||
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
bool execSql(sqlite3* db, const char* sql, std::string& err)
|
||||
{
|
||||
char* msg = nullptr;
|
||||
const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg);
|
||||
if (rc != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : sqlite3_errstr(rc);
|
||||
sqlite3_free(msg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* kSchemaSql = R"SQL(
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
name TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS layout_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maps (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
width REAL,
|
||||
height REAL,
|
||||
resolution REAL,
|
||||
origin_x REAL DEFAULT 0,
|
||||
origin_y REAL DEFAULT 0,
|
||||
origin_yaw REAL DEFAULT 0,
|
||||
image_file TEXT,
|
||||
yaml_file TEXT,
|
||||
zones_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sounds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
file_name TEXT,
|
||||
duration_ms INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
map_id TEXT,
|
||||
file_path TEXT,
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_by_user TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_edit_groups (
|
||||
dashboard_id TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dashboard_id, group_id),
|
||||
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
dashboard_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
mission_id TEXT,
|
||||
mission_group TEXT,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (dashboard_id) REFERENCES dashboards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
active_dashboard_id TEXT
|
||||
);
|
||||
)SQL";
|
||||
|
||||
} // namespace
|
||||
|
||||
Database::Database(std::filesystem::path data_dir)
|
||||
: data_dir_(std::move(data_dir)), db_path_(data_dir_ / "RBS.db")
|
||||
{
|
||||
}
|
||||
|
||||
void Database::close()
|
||||
{
|
||||
if (db_)
|
||||
{
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool Database::openDb(std::string& err)
|
||||
{
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(data_dir_, ec);
|
||||
|
||||
const auto legacy_path = data_dir_ / "test3.db";
|
||||
if (!std::filesystem::exists(db_path_) && std::filesystem::exists(legacy_path))
|
||||
{
|
||||
std::filesystem::rename(legacy_path, db_path_, ec);
|
||||
for (const char* suffix : {"-wal", "-shm"})
|
||||
{
|
||||
const auto from = legacy_path.string() + suffix;
|
||||
const auto to = db_path_.string() + suffix;
|
||||
if (std::filesystem::exists(from))
|
||||
std::filesystem::rename(from, to, ec);
|
||||
}
|
||||
}
|
||||
|
||||
const int rc = sqlite3_open(db_path_.string().c_str(), &db_);
|
||||
if (rc != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_);
|
||||
db_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_busy_timeout(db_, 5000);
|
||||
if (!execSql(db_, "PRAGMA journal_mode=WAL;", err))
|
||||
return false;
|
||||
if (!execSql(db_, "PRAGMA synchronous=NORMAL;", err))
|
||||
return false;
|
||||
if (!execSql(db_, "PRAGMA foreign_keys=ON;", err))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::ensureDataDirs(std::string& err)
|
||||
{
|
||||
std::error_code ec;
|
||||
for (const auto& dir : {mapsDir(), soundsDir(), recordingsDir()})
|
||||
{
|
||||
if (!std::filesystem::create_directories(dir, ec) && ec)
|
||||
{
|
||||
err = "failed to create directory: " + dir.string();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::applySchema(std::string& err)
|
||||
{
|
||||
return execSql(db_, kSchemaSql, err);
|
||||
}
|
||||
|
||||
std::optional<std::string> Database::getMeta(const std::string& key) const
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "SELECT value FROM meta WHERE key = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<std::string> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* val = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (val)
|
||||
out = val;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Database::setMeta(const std::string& key, const std::string& value)
|
||||
{
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO meta(key, value) VALUES(?1, ?2) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Database::getDocument(const std::string& name, nlohmann::json& out) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "SELECT content FROM documents WHERE name = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
bool found = false;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (text)
|
||||
{
|
||||
try
|
||||
{
|
||||
out = nlohmann::json::parse(text);
|
||||
found = true;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
found = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool Database::setDocument(const std::string& name, const nlohmann::json& doc)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string body = doc.dump();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO documents(name, content, updated_at) VALUES(?1, ?2, ?3) "
|
||||
"ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> Database::getLayoutProfile(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "SELECT content FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
const char* text = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (text)
|
||||
{
|
||||
try
|
||||
{
|
||||
out = nlohmann::json::parse(text);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
out = std::nullopt;
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool Database::setLayoutProfile(const nlohmann::json& profile)
|
||||
{
|
||||
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
||||
return false;
|
||||
const std::string id = profile["id"].get<std::string>();
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string body = profile.dump();
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO layout_profiles(id, content, updated_at) VALUES(?1, ?2, ?3) "
|
||||
"ON CONFLICT(id) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, body.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Database::deleteLayoutProfile(const std::string& id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM layout_profiles WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
return false;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Database::migrateFromJsonIfNeeded(std::string& err)
|
||||
{
|
||||
if (getMeta("json_imported"))
|
||||
return true;
|
||||
|
||||
const auto importDoc = [&](const std::string& name, const std::filesystem::path& path) {
|
||||
if (!std::filesystem::exists(path))
|
||||
return;
|
||||
try
|
||||
{
|
||||
const auto doc = nlohmann::json::parse(FileUtil::readBinary(path));
|
||||
setDocument(name, doc);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
importDoc("auth", data_dir_ / "auth.json");
|
||||
importDoc("missions", data_dir_ / "missions.json");
|
||||
importDoc("mission_queue", data_dir_ / "mission_queue.json");
|
||||
importDoc("robot_runtime", data_dir_ / "robot_runtime.json");
|
||||
|
||||
const auto state_path = data_dir_ / "state.json";
|
||||
if (std::filesystem::exists(state_path))
|
||||
{
|
||||
try
|
||||
{
|
||||
setDocument("state", nlohmann::json::parse(FileUtil::readBinary(state_path)));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
const auto models_dir = data_dir_ / "models";
|
||||
if (std::filesystem::is_directory(models_dir))
|
||||
{
|
||||
for (const auto& entry : std::filesystem::directory_iterator(models_dir))
|
||||
{
|
||||
if (!entry.is_regular_file() || entry.path().extension() != ".json")
|
||||
continue;
|
||||
try
|
||||
{
|
||||
const auto profile = nlohmann::json::parse(FileUtil::readBinary(entry.path()));
|
||||
setLayoutProfile(profile);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMeta("schema_version", "1");
|
||||
setMeta("json_imported", IdUtil::nowIso8601());
|
||||
std::fprintf(stderr, "SQLite: imported JSON data into %s\n", db_path_.string().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Database::init(std::string& err)
|
||||
{
|
||||
if (!openDb(err))
|
||||
return false;
|
||||
if (!applySchema(err))
|
||||
return false;
|
||||
if (!ensureDataDirs(err))
|
||||
return false;
|
||||
if (!migrateFromJsonIfNeeded(err))
|
||||
return false;
|
||||
if (!getMeta("schema_version"))
|
||||
setMeta("schema_version", "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
51
src/storage/database.hpp
Normal file
51
src/storage/database.hpp
Normal file
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database
|
||||
{
|
||||
public:
|
||||
explicit Database(std::filesystem::path data_dir);
|
||||
|
||||
bool init(std::string& err);
|
||||
void close();
|
||||
|
||||
std::filesystem::path dataDir() const { return data_dir_; }
|
||||
std::filesystem::path dbPath() const { return db_path_; }
|
||||
std::filesystem::path mapsDir() const { return data_dir_ / "maps"; }
|
||||
std::filesystem::path soundsDir() const { return data_dir_ / "sounds"; }
|
||||
std::filesystem::path recordingsDir() const { return data_dir_ / "recordings"; }
|
||||
|
||||
bool getDocument(const std::string& name, nlohmann::json& out) const;
|
||||
bool setDocument(const std::string& name, const nlohmann::json& doc);
|
||||
|
||||
std::optional<nlohmann::json> getLayoutProfile(const std::string& id) const;
|
||||
bool setLayoutProfile(const nlohmann::json& profile);
|
||||
bool deleteLayoutProfile(const std::string& id);
|
||||
|
||||
sqlite3* handle() const { return db_; }
|
||||
|
||||
private:
|
||||
std::filesystem::path data_dir_;
|
||||
std::filesystem::path db_path_;
|
||||
sqlite3* db_ = nullptr;
|
||||
mutable std::mutex mu_;
|
||||
|
||||
bool openDb(std::string& err);
|
||||
bool applySchema(std::string& err);
|
||||
bool migrateFromJsonIfNeeded(std::string& err);
|
||||
bool ensureDataDirs(std::string& err);
|
||||
std::optional<std::string> getMeta(const std::string& key) const;
|
||||
bool setMeta(const std::string& key, const std::string& value);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
338
src/storage/map_store.cpp
Normal file
338
src/storage/map_store.cpp
Normal file
@@ -0,0 +1,338 @@
|
||||
#include "storage/map_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
|
||||
};
|
||||
auto realOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(sqlite3_column_double(stmt, col));
|
||||
};
|
||||
|
||||
nlohmann::json zones = nlohmann::json::array();
|
||||
if (sqlite3_column_type(stmt, 11) != SQLITE_NULL)
|
||||
{
|
||||
try
|
||||
{
|
||||
zones = nlohmann::json::parse(reinterpret_cast<const char*>(sqlite3_column_text(stmt, 11)));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
zones = nlohmann::json::array();
|
||||
}
|
||||
}
|
||||
|
||||
return {{"id", textOrNull(0)},
|
||||
{"name", textOrNull(1)},
|
||||
{"description", textOrNull(2)},
|
||||
{"width", realOrNull(3)},
|
||||
{"height", realOrNull(4)},
|
||||
{"resolution", realOrNull(5)},
|
||||
{"origin_x", realOrNull(6)},
|
||||
{"origin_y", realOrNull(7)},
|
||||
{"origin_yaw", realOrNull(8)},
|
||||
{"image_file", textOrNull(9)},
|
||||
{"yaml_file", textOrNull(10)},
|
||||
{"zones", zones},
|
||||
{"created_at", textOrNull(12)},
|
||||
{"updated_at", textOrNull(13)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MapStore::MapStore(Database& db) : db_(db) {}
|
||||
|
||||
std::filesystem::path MapStore::mapDir(const std::string& id) const
|
||||
{
|
||||
return db_.mapsDir() / id;
|
||||
}
|
||||
|
||||
nlohmann::json MapStore::list() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json maps = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps ORDER BY name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return maps;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
maps.push_back(rowToJson(stmt));
|
||||
sqlite3_finalize(stmt);
|
||||
return maps;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MapStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at FROM maps WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MapStore::create(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string description = payload.value("description", "");
|
||||
const auto zones = payload.contains("zones") ? payload["zones"] : nlohmann::json::array();
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(mapDir(id), ec);
|
||||
if (ec)
|
||||
{
|
||||
err = "failed to create map directory";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO maps(id, name, description, width, height, resolution, "
|
||||
"origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, "
|
||||
"created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 4, payload["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 5, payload["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, payload["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
sqlite3_bind_double(stmt, 7, payload.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 8, payload.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, payload.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_null(stmt, 10);
|
||||
sqlite3_bind_null(stmt, 11);
|
||||
const std::string zones_str = zones.dump();
|
||||
sqlite3_bind_text(stmt, 12, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 13, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 14, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
nlohmann::json created;
|
||||
created["id"] = id;
|
||||
created["name"] = name;
|
||||
created["description"] = description;
|
||||
if (payload.contains("width") && payload["width"].is_number())
|
||||
created["width"] = payload["width"];
|
||||
if (payload.contains("height") && payload["height"].is_number())
|
||||
created["height"] = payload["height"];
|
||||
if (payload.contains("resolution") && payload["resolution"].is_number())
|
||||
created["resolution"] = payload["resolution"];
|
||||
created["origin_x"] = payload.value("origin_x", 0.0);
|
||||
created["origin_y"] = payload.value("origin_y", 0.0);
|
||||
created["origin_yaw"] = payload.value("origin_yaw", 0.0);
|
||||
created["image_file"] = nullptr;
|
||||
created["yaml_file"] = nullptr;
|
||||
created["zones"] = zones;
|
||||
created["created_at"] = now;
|
||||
created["updated_at"] = now;
|
||||
return created;
|
||||
}
|
||||
|
||||
bool MapStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "map not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key : {"name", "description", "width", "height", "resolution", "origin_x", "origin_y", "origin_yaw"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
}
|
||||
if (payload.contains("zones"))
|
||||
merged["zones"] = payload["zones"];
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string zones_str = merged.value("zones", nlohmann::json::array()).dump();
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE maps SET name=?2, description=?3, width=?4, height=?5, resolution=?6, "
|
||||
"origin_x=?7, origin_y=?8, origin_yaw=?9, zones_json=?10, updated_at=?11 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (merged["width"].is_number())
|
||||
sqlite3_bind_double(stmt, 4, merged["width"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 4);
|
||||
if (merged["height"].is_number())
|
||||
sqlite3_bind_double(stmt, 5, merged["height"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
if (merged["resolution"].is_number())
|
||||
sqlite3_bind_double(stmt, 6, merged["resolution"].get<double>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 6);
|
||||
sqlite3_bind_double(stmt, 7, merged.value("origin_x", 0.0));
|
||||
sqlite3_bind_double(stmt, 8, merged.value("origin_y", 0.0));
|
||||
sqlite3_bind_double(stmt, 9, merged.value("origin_yaw", 0.0));
|
||||
sqlite3_bind_text(stmt, 10, zones_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 11, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool MapStore::remove(const std::string& id, std::string& err)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM maps WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!ok)
|
||||
{
|
||||
err = "map not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(mapDir(id), ec);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> MapStore::imagePath(const std::string& id) const
|
||||
{
|
||||
const auto map = find(id);
|
||||
if (!map || !(*map)["image_file"].is_string())
|
||||
return std::nullopt;
|
||||
const auto path = mapDir(id) / map->value("image_file", "");
|
||||
if (!std::filesystem::exists(path))
|
||||
return std::nullopt;
|
||||
return path;
|
||||
}
|
||||
|
||||
bool MapStore::saveImageFile(const std::string& id,
|
||||
const std::string& filename,
|
||||
const std::string& bytes,
|
||||
std::string& err)
|
||||
{
|
||||
if (!find(id))
|
||||
{
|
||||
err = "map not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(mapDir(id), ec);
|
||||
const auto path = mapDir(id) / filename;
|
||||
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||
{
|
||||
err = "failed to write image file";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE maps SET image_file = ?2, updated_at = ?3 WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
34
src/storage/map_store.hpp
Normal file
34
src/storage/map_store.hpp
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class MapStore
|
||||
{
|
||||
public:
|
||||
MapStore(Database& db);
|
||||
|
||||
nlohmann::json list() const;
|
||||
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||
bool remove(const std::string& id, std::string& err);
|
||||
|
||||
std::filesystem::path mapDir(const std::string& id) const;
|
||||
std::optional<std::filesystem::path> imagePath(const std::string& id) const;
|
||||
bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
257
src/storage/sound_store.cpp
Normal file
257
src/storage/sound_store.cpp
Normal file
@@ -0,0 +1,257 @@
|
||||
#include "storage/sound_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
nlohmann::json rowToJson(sqlite3_stmt* stmt)
|
||||
{
|
||||
auto textOrNull = [&](int col) -> nlohmann::json {
|
||||
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
|
||||
return nullptr;
|
||||
return nlohmann::json(reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)));
|
||||
};
|
||||
return {{"id", textOrNull(0)},
|
||||
{"name", textOrNull(1)},
|
||||
{"description", textOrNull(2)},
|
||||
{"file_name", textOrNull(3)},
|
||||
{"duration_ms", sqlite3_column_type(stmt, 4) == SQLITE_NULL
|
||||
? nlohmann::json(nullptr)
|
||||
: nlohmann::json(sqlite3_column_int(stmt, 4))},
|
||||
{"enabled", sqlite3_column_int(stmt, 5) != 0},
|
||||
{"created_at", textOrNull(6)},
|
||||
{"updated_at", textOrNull(7)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SoundStore::SoundStore(Database& db) : db_(db) {}
|
||||
|
||||
nlohmann::json SoundStore::list() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json sounds = nlohmann::json::array();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
||||
"FROM sounds ORDER BY name",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return sounds;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
sounds.push_back(rowToJson(stmt));
|
||||
sqlite3_finalize(stmt);
|
||||
return sounds;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> SoundStore::find(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at "
|
||||
"FROM sounds WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
return std::nullopt;
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
std::optional<nlohmann::json> out;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
out = rowToJson(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> SoundStore::create(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string name = StringUtil::trimCopy(payload.value("name", ""));
|
||||
if (name.empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string id = payload.value("id", IdUtil::newId());
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
const std::string description = payload.value("description", "");
|
||||
const bool enabled = payload.value("enabled", true);
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, created_at, updated_at) "
|
||||
"VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 4, enabled ? 1 : 0);
|
||||
sqlite3_bind_text(stmt, 5, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
return nlohmann::json{{"id", id},
|
||||
{"name", name},
|
||||
{"description", description},
|
||||
{"file_name", nullptr},
|
||||
{"duration_ms", nullptr},
|
||||
{"enabled", enabled},
|
||||
{"created_at", now},
|
||||
{"updated_at", now}};
|
||||
}
|
||||
|
||||
bool SoundStore::update(const std::string& id, const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "sound not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
nlohmann::json merged = *existing;
|
||||
for (const char* key : {"name", "description", "enabled", "duration_ms"})
|
||||
{
|
||||
if (payload.contains(key))
|
||||
merged[key] = payload[key];
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, updated_at=?6 WHERE id=?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, merged.value("name", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, merged.value("description", "").c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 4, merged.value("enabled", true) ? 1 : 0);
|
||||
if (merged["duration_ms"].is_number_integer())
|
||||
sqlite3_bind_int(stmt, 5, merged["duration_ms"].get<int>());
|
||||
else
|
||||
sqlite3_bind_null(stmt, 5);
|
||||
sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool SoundStore::remove(const std::string& id, std::string& err)
|
||||
{
|
||||
auto existing = find(id);
|
||||
if (!existing)
|
||||
{
|
||||
err = "sound not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (existing->contains("file_name") && (*existing)["file_name"].is_string())
|
||||
{
|
||||
const auto path = db_.soundsDir() / existing->value("file_name", "");
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(path, ec);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> SoundStore::filePath(const std::string& id) const
|
||||
{
|
||||
const auto sound = find(id);
|
||||
if (!sound || !(*sound)["file_name"].is_string())
|
||||
return std::nullopt;
|
||||
const auto path = db_.soundsDir() / sound->value("file_name", "");
|
||||
if (!std::filesystem::exists(path))
|
||||
return std::nullopt;
|
||||
return path;
|
||||
}
|
||||
|
||||
bool SoundStore::saveFile(const std::string& id,
|
||||
const std::string& filename,
|
||||
const std::string& bytes,
|
||||
std::string& err)
|
||||
{
|
||||
if (!find(id))
|
||||
{
|
||||
err = "sound not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(db_.soundsDir(), ec);
|
||||
const auto path = db_.soundsDir() / filename;
|
||||
if (!FileUtil::writeBinaryAtomic(path, bytes))
|
||||
{
|
||||
err = "failed to write sound file";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_.handle(),
|
||||
"UPDATE sounds SET file_name = ?2, updated_at = ?3 WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, filename.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db_.handle());
|
||||
sqlite3_finalize(stmt);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
33
src/storage/sound_store.hpp
Normal file
33
src/storage/sound_store.hpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class SoundStore
|
||||
{
|
||||
public:
|
||||
SoundStore(Database& db);
|
||||
|
||||
nlohmann::json list() const;
|
||||
std::optional<nlohmann::json> find(const std::string& id) const;
|
||||
std::optional<nlohmann::json> create(const nlohmann::json& payload, std::string& err);
|
||||
bool update(const std::string& id, const nlohmann::json& payload, std::string& err);
|
||||
bool remove(const std::string& id, std::string& err);
|
||||
|
||||
std::optional<std::filesystem::path> filePath(const std::string& id) const;
|
||||
bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "domain/layout_profile.hpp"
|
||||
#include "domain/layout_schema.hpp"
|
||||
#include "storage/database.hpp"
|
||||
#include "util/file_util.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
#include "util/string_util.hpp"
|
||||
@@ -20,6 +21,8 @@ std::filesystem::path StateRepository::profileFilePath(const std::string& id) co
|
||||
|
||||
std::optional<nlohmann::json> StateRepository::loadProfileFromDisk(const std::string& id) const
|
||||
{
|
||||
if (auto profile = db_.getLayoutProfile(id))
|
||||
return profile;
|
||||
const auto raw = FileUtil::readBinary(profileFilePath(id));
|
||||
if (raw.empty())
|
||||
return std::nullopt;
|
||||
@@ -37,15 +40,12 @@ bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const
|
||||
{
|
||||
if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string())
|
||||
return false;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(modelsDir(), ec);
|
||||
auto body = profile.dump(2);
|
||||
body.push_back('\n');
|
||||
return FileUtil::writeBinaryAtomic(profileFilePath(profile["id"].get<std::string>()), body);
|
||||
return db_.setLayoutProfile(profile);
|
||||
}
|
||||
|
||||
bool StateRepository::deleteProfileFile(const std::string& id) const
|
||||
{
|
||||
db_.deleteLayoutProfile(id);
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(profileFilePath(id), ec);
|
||||
return true;
|
||||
@@ -243,15 +243,14 @@ void StateRepository::bootstrapDefaultState()
|
||||
app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array();
|
||||
}
|
||||
|
||||
StateRepository::StateRepository(std::filesystem::path data_path)
|
||||
StateRepository::StateRepository(std::filesystem::path data_path, Database& db) : db_(db)
|
||||
{
|
||||
app_.data_path = std::move(data_path);
|
||||
}
|
||||
|
||||
bool StateRepository::load()
|
||||
{
|
||||
const auto raw = FileUtil::readBinary(app_.data_path);
|
||||
if (raw.empty())
|
||||
if (!db_.getDocument("state", app_.state))
|
||||
{
|
||||
bootstrapDefaultState();
|
||||
save();
|
||||
@@ -259,7 +258,6 @@ bool StateRepository::load()
|
||||
}
|
||||
try
|
||||
{
|
||||
app_.state = nlohmann::json::parse(raw);
|
||||
ensureSchema();
|
||||
save();
|
||||
return true;
|
||||
@@ -309,9 +307,7 @@ bool StateRepository::save() const
|
||||
try
|
||||
{
|
||||
const nlohmann::json disk = globalStateForDisk(app_.state);
|
||||
auto raw = disk.dump(2);
|
||||
raw.push_back('\n');
|
||||
return FileUtil::writeBinaryAtomic(app_.data_path, raw);
|
||||
return db_.setDocument("state", disk);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class StateRepository
|
||||
{
|
||||
public:
|
||||
explicit StateRepository(std::filesystem::path data_path);
|
||||
StateRepository(std::filesystem::path data_path, Database& db);
|
||||
|
||||
AppState& app() { return app_; }
|
||||
const AppState& app() const { return app_; }
|
||||
@@ -30,6 +32,7 @@ public:
|
||||
|
||||
private:
|
||||
AppState app_;
|
||||
Database& db_;
|
||||
|
||||
std::filesystem::path modelsDir() const;
|
||||
std::filesystem::path profileFilePath(const std::string& id) const;
|
||||
|
||||
Reference in New Issue
Block a user