413 lines
11 KiB
C++
413 lines
11 KiB
C++
#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
|