diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e7ba32..182cd39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,7 @@ add_executable(lidar_manager_web src/io/io_zone_runtime.cpp src/io/io_module_usage.cpp src/storage/path_store.cpp + src/storage/path_guide_store.cpp src/path/path_planner.cpp src/path/path_service.cpp src/storage/dashboard_store.cpp @@ -70,6 +71,7 @@ add_executable(lidar_manager_web src/server/api_transition_routes.cpp src/server/api_io_module_routes.cpp src/server/api_path_routes.cpp + src/server/api_path_guide_routes.cpp src/server/api_dashboard_routes.cpp ) diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index b02db5e..01dab11 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -5,6 +5,7 @@ #include "io/io_zone_runtime.hpp" #include "path/path_service.hpp" #include "storage/path_store.hpp" +#include "storage/path_guide_store.hpp" #include "mission/mission_enqueue.hpp" #include "mission/mission_queue.hpp" #include "mission/mission_scheduler.hpp" @@ -73,6 +74,7 @@ int LidarManagerApp::run() IoModuleService io_module_service(io_module_store); IoZoneRuntime io_zone_runtime; PathStore path_store(database); + PathGuideStore path_guide_store(database); PathService path_service(path_store, map_store); MissionStore mission_store(database); MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime, @@ -118,6 +120,7 @@ int LidarManagerApp::run() io_module_store, io_module_service, path_store, + path_guide_store, path_service, dashboard_store); api.registerRoutes(svr); diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp index 988efab..23ffd06 100644 --- a/src/server/api_media_routes.cpp +++ b/src/server/api_media_routes.cpp @@ -88,6 +88,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) {"transitions", transition_store_.list(site_id)}, {"io_modules", io_module_store_.list(site_id)}, {"paths", path_store_.list(site_id)}, + {"path_guides", path_guide_store_.list(site_id)}, {"sounds", sound_store_.list()}}; res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = bundle.dump(2); @@ -114,6 +115,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) nlohmann::json imported = {{"maps", nlohmann::json::array()}, {"io_modules", nlohmann::json::array()}, {"paths", nlohmann::json::array()}, + {"path_guides", nlohmann::json::array()}, {"transitions", nlohmann::json::array()}}; std::unordered_map map_id_remap; @@ -181,6 +183,33 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) } } + if (bundle.contains("path_guides") && bundle["path_guides"].is_array()) + { + for (const auto& guide : bundle["path_guides"]) + { + if (!guide.is_object()) + continue; + nlohmann::json payload = guide; + payload.erase("id"); + payload["site_id"] = site_id; + const std::string old_map = payload.value("map_id", ""); + if (map_id_remap.count(old_map)) + payload["map_id"] = map_id_remap[old_map]; + if (const AuthSession* session = AuthService::activeSession()) + { + if (!payload.contains("created_by") || payload["created_by"].get().empty()) + payload["created_by"] = session->group_name.empty() ? session->username : session->group_name; + if (!payload.contains("created_by_group") || payload["created_by_group"].get().empty()) + payload["created_by_group"] = session->group_id; + } + std::string err; + const auto created = path_guide_store_.create(payload, err); + if (!created) + return HttpUtil::jsonError(res, 400, "path guide import failed: " + err); + imported["path_guides"].push_back(*created); + } + } + if (bundle.contains("transitions") && bundle["transitions"].is_array()) { for (const auto& tr : bundle["transitions"]) diff --git a/src/server/api_path_guide_routes.cpp b/src/server/api_path_guide_routes.cpp new file mode 100644 index 0000000..ca783a9 --- /dev/null +++ b/src/server/api_path_guide_routes.cpp @@ -0,0 +1,96 @@ +#include "server/api_server.hpp" + +#include "auth/auth_service.hpp" +#include "util/http_util.hpp" + +namespace lm { + +void ApiServer::registerPathGuideRoutes(httplib::Server& svr) +{ + svr.Get("/api/path_guides", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string site_id = req.has_param("site_id") ? req.get_param_value("site_id") : ""; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"path_guides", path_guide_store_.list(site_id)}}).dump(); + }); + + svr.Get(R"(/api/path_guides/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto guide = path_guide_store_.find(id); + if (!guide) + return HttpUtil::jsonError(res, 404, "path guide not found"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = guide->dump(); + }); + + svr.Post("/api/path_guides", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + if (const AuthSession* session = AuthService::activeSession()) + { + if (!body.contains("created_by") || !body["created_by"].is_string() || + body["created_by"].get().empty()) + { + body["created_by"] = session->group_name.empty() ? session->username : session->group_name; + } + if (!body.contains("created_by_group") || !body["created_by_group"].is_string() || + body["created_by_group"].get().empty()) + body["created_by_group"] = session->group_id; + } + std::string err; + const auto created = path_guide_store_.create(body, err); + if (!created) + return HttpUtil::jsonError(res, 400, err); + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = created->dump(); + }); + + svr.Put(R"(/api/path_guides/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + std::string err; + if (!path_guide_store_.update(id, body, err)) + return HttpUtil::jsonError(res, 400, err); + const auto updated = path_guide_store_.find(id); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = updated ? updated->dump() : nlohmann::json::object().dump(); + }); + + svr.Delete(R"(/api/path_guides/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto existing = path_guide_store_.find(id); + if (!existing) + return HttpUtil::jsonError(res, 404, "path guide not found"); + if (const AuthSession* session = AuthService::activeSession()) + { + if (!AuthService::canDeleteMap(*existing, *session)) + return HttpUtil::jsonError(res, 403, "cannot delete path guide from another user group"); + } + std::string err; + if (!path_guide_store_.remove(id, err)) + return HttpUtil::jsonError(res, 400, err); + res.status = 204; + }); +} + +} // namespace lm diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index b21ba01..014ccf4 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -23,6 +23,7 @@ ApiServer::ApiServer(StateRepository& repo, IoModuleStore& io_module_store, IoModuleService& io_module_service, PathStore& path_store, + PathGuideStore& path_guide_store, PathService& path_service, DashboardStore& dashboard_store) : repo_(repo), @@ -38,6 +39,7 @@ ApiServer::ApiServer(StateRepository& repo, io_module_store_(io_module_store), io_module_service_(io_module_service), path_store_(path_store), + path_guide_store_(path_guide_store), path_service_(path_service), dashboard_store_(dashboard_store) { @@ -563,6 +565,7 @@ void ApiServer::registerRoutes(httplib::Server& svr) registerTransitionRoutes(svr); registerIoModuleRoutes(svr); registerPathRoutes(svr); + registerPathGuideRoutes(svr); registerDashboardRoutes(svr); } diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index d2f7b91..df0c079 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -14,6 +14,7 @@ #include "storage/io_module_store.hpp" #include "io/io_module_service.hpp" #include "storage/path_store.hpp" +#include "storage/path_guide_store.hpp" #include "path/path_service.hpp" #include "storage/transition_store.hpp" #include "storage/state_repository.hpp" @@ -36,6 +37,7 @@ public: IoModuleStore& io_module_store, IoModuleService& io_module_service, PathStore& path_store, + PathGuideStore& path_guide_store, PathService& path_service, DashboardStore& dashboard_store); @@ -55,6 +57,7 @@ private: IoModuleStore& io_module_store_; IoModuleService& io_module_service_; PathStore& path_store_; + PathGuideStore& path_guide_store_; PathService& path_service_; DashboardStore& dashboard_store_; @@ -69,6 +72,7 @@ private: void registerTransitionRoutes(httplib::Server& svr); void registerIoModuleRoutes(httplib::Server& svr); void registerPathRoutes(httplib::Server& svr); + void registerPathGuideRoutes(httplib::Server& svr); void registerDashboardRoutes(httplib::Server& svr); }; diff --git a/src/storage/database.cpp b/src/storage/database.cpp index e56cd12..58242f1 100644 --- a/src/storage/database.cpp +++ b/src/storage/database.cpp @@ -137,6 +137,20 @@ CREATE TABLE IF NOT EXISTS paths ( FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS path_guides ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL, + map_id TEXT NOT NULL, + name TEXT NOT NULL, + positions_json TEXT NOT NULL DEFAULT '[]', + created_by TEXT NOT NULL DEFAULT '', + created_by_group TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, + FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS dashboards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -419,8 +433,11 @@ bool Database::applySchemaMigrations(std::string& err) ver = getMeta("schema_version").value_or("1"); if (ver == "5") { - if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err)) - return false; + if (!tableHasColumn(db_, "transitions", "created_by")) + { + if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err)) + return false; + } setMeta("schema_version", "6"); } @@ -469,6 +486,28 @@ bool Database::applySchemaMigrations(std::string& err) setMeta("schema_version", "8"); } + ver = getMeta("schema_version").value_or("1"); + if (ver == "8") + { + if (!execSql(db_, + "CREATE TABLE IF NOT EXISTS path_guides (" + "id TEXT PRIMARY KEY, " + "site_id TEXT NOT NULL, " + "map_id TEXT NOT NULL, " + "name TEXT NOT NULL, " + "positions_json TEXT NOT NULL DEFAULT '[]', " + "created_by TEXT NOT NULL DEFAULT '', " + "created_by_group TEXT NOT NULL DEFAULT '', " + "created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL, " + "FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, " + "FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE" + ")", + err)) + return false; + setMeta("schema_version", "9"); + } + return true; } diff --git a/src/storage/path_guide_store.cpp b/src/storage/path_guide_store.cpp new file mode 100644 index 0000000..5f9f1cd --- /dev/null +++ b/src/storage/path_guide_store.cpp @@ -0,0 +1,391 @@ +#include "storage/path_guide_store.hpp" + +#include "storage/database.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +#include + +#include + +namespace lm { + +namespace { + +nlohmann::json parsePositionsJson(const std::string& raw) +{ + if (raw.empty()) + return nlohmann::json::array(); + try + { + const auto parsed = nlohmann::json::parse(raw); + return parsed.is_array() ? parsed : nlohmann::json::array(); + } + catch (...) + { + return nlohmann::json::array(); + } +} + +nlohmann::json rowToJson(sqlite3_stmt* stmt) +{ + auto text = [&](int col) -> std::string { + if (sqlite3_column_type(stmt, col) == SQLITE_NULL) + return ""; + const char* v = reinterpret_cast(sqlite3_column_text(stmt, col)); + return v ? std::string(v) : ""; + }; + + const nlohmann::json positions = parsePositionsJson(text(4)); + int starts_count = 0; + int vias_count = 0; + int goals_count = 0; + for (const auto& pos : positions) + { + if (!pos.is_object()) + continue; + const std::string role = StringUtil::toLower(pos.value("role", "")); + if (role == "start") + ++starts_count; + else if (role == "via") + ++vias_count; + else if (role == "goal") + ++goals_count; + } + + return {{"id", text(0)}, + {"site_id", text(1)}, + {"map_id", text(2)}, + {"name", text(3)}, + {"positions", positions}, + {"starts_count", starts_count}, + {"vias_count", vias_count}, + {"goals_count", goals_count}, + {"created_by", text(5)}, + {"created_by_group", text(6)}, + {"created_at", text(7)}, + {"updated_at", text(8)}}; +} + +constexpr const char* kSelect = + "SELECT id, site_id, map_id, name, positions_json, created_by, created_by_group, created_at, updated_at " + "FROM path_guides"; + +bool validRole(const std::string& role) +{ + return role == "start" || role == "via" || role == "goal"; +} + +std::optional normalizePositions(const nlohmann::json& raw, std::string& err) +{ + if (!raw.is_array()) + { + err = "positions must be an array"; + return std::nullopt; + } + + nlohmann::json out = nlohmann::json::array(); + int starts = 0; + int goals = 0; + int via_priority = 0; + + for (const auto& item : raw) + { + if (!item.is_object()) + continue; + const std::string position_id = StringUtil::trimCopy(item.value("position_id", "")); + const std::string role = StringUtil::toLower(StringUtil::trimCopy(item.value("role", ""))); + if (position_id.empty() || !validRole(role)) + { + err = "invalid position entry"; + return std::nullopt; + } + + nlohmann::json entry = {{"position_id", position_id}, {"role", role}}; + if (role == "via") + { + int priority = via_priority + 1; + if (item.contains("priority") && item["priority"].is_number_integer()) + priority = item["priority"].get(); + if (priority <= 0) + priority = via_priority + 1; + via_priority = priority; + entry["priority"] = priority; + } + else if (role == "start") + { + ++starts; + } + else if (role == "goal") + { + ++goals; + } + out.push_back(std::move(entry)); + } + + if (starts < 1 || goals < 1) + { + err = "path guide requires at least one start and one goal"; + return std::nullopt; + } + + std::sort(out.begin(), out.end(), [](const nlohmann::json& a, const nlohmann::json& b) { + const std::string ra = a.value("role", ""); + const std::string rb = b.value("role", ""); + if (ra != rb) + { + if (ra == "start") + return true; + if (rb == "start") + return false; + if (ra == "via") + return true; + return false; + } + if (ra == "via") + return a.value("priority", 0) < b.value("priority", 0); + return a.value("position_id", "") < b.value("position_id", ""); + }); + + return out; +} + +} // namespace + +PathGuideStore::PathGuideStore(Database& db) : db_(db) {} + +bool PathGuideStore::findNameConflictUnlocked(const std::string& site_id, + const std::string& name, + const std::string& except_id) const +{ + const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name)); + if (needle.empty()) + return false; + + sqlite3_stmt* stmt = nullptr; + const char* sql = except_id.empty() + ? "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) LIMIT 1" + : "SELECT id FROM path_guides WHERE site_id = ?1 AND lower(name) = lower(?2) AND id != ?3 LIMIT 1"; + if (sqlite3_prepare_v2(db_.handle(), sql, -1, &stmt, nullptr) != SQLITE_OK) + return false; + sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT); + if (!except_id.empty()) + sqlite3_bind_text(stmt, 3, except_id.c_str(), -1, SQLITE_TRANSIENT); + const bool conflict = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return conflict; +} + +nlohmann::json PathGuideStore::list(const std::string& site_id) const +{ + std::lock_guard lock(mu_); + nlohmann::json out = nlohmann::json::array(); + std::string sql = kSelect; + if (!site_id.empty()) + sql += " WHERE site_id = ?1"; + sql += " ORDER BY site_id, map_id, name"; + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) + return out; + if (!site_id.empty()) + sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT); + while (sqlite3_step(stmt) == SQLITE_ROW) + out.push_back(rowToJson(stmt)); + sqlite3_finalize(stmt); + return out; +} + +std::optional PathGuideStore::find(const std::string& id) const +{ + std::lock_guard lock(mu_); + const std::string sql = std::string(kSelect) + " WHERE id = ?1"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) + return std::nullopt; + sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); + std::optional out; + if (sqlite3_step(stmt) == SQLITE_ROW) + out = rowToJson(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional PathGuideStore::create(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be an object"; + return std::nullopt; + } + + const std::string site_id = StringUtil::trimCopy(payload.value("site_id", "")); + const std::string map_id = StringUtil::trimCopy(payload.value("map_id", "")); + const std::string name = StringUtil::trimCopy(payload.value("name", "")); + const std::string created_by = StringUtil::trimCopy(payload.value("created_by", "")); + const std::string created_by_group = StringUtil::trimCopy(payload.value("created_by_group", "")); + const auto positions = normalizePositions(payload.value("positions", nlohmann::json::array()), err); + if (!positions) + return std::nullopt; + + if (site_id.empty() || map_id.empty() || name.empty()) + { + err = "missing required fields"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + if (findNameConflictUnlocked(site_id, name, "")) + { + err = "path guide name already exists for this site"; + return std::nullopt; + } + + const std::string id = payload.value("id", IdUtil::newId()); + const std::string now = IdUtil::nowIso8601(); + const std::string positions_json = positions->dump(); + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "INSERT INTO path_guides(id, site_id, map_id, name, positions_json, created_by, " + "created_by_group, created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)", + -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, site_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, created_by.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 7, created_by_group.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 9, 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 = {{"id", id}, + {"site_id", site_id}, + {"map_id", map_id}, + {"name", name}, + {"positions", *positions}, + {"created_by", created_by}, + {"created_by_group", created_by_group}, + {"created_at", now}, + {"updated_at", now}}; + int starts_count = 0; + int vias_count = 0; + int goals_count = 0; + for (const auto& pos : *positions) + { + const std::string role = pos.value("role", ""); + if (role == "start") + ++starts_count; + else if (role == "via") + ++vias_count; + else if (role == "goal") + ++goals_count; + } + created["starts_count"] = starts_count; + created["vias_count"] = vias_count; + created["goals_count"] = goals_count; + return created; +} + +bool PathGuideStore::update(const std::string& id, const nlohmann::json& payload, std::string& err) +{ + auto existing = find(id); + if (!existing) + { + err = "path guide not found"; + return false; + } + + nlohmann::json merged = *existing; + for (const char* key : {"site_id", "map_id", "name"}) + { + if (payload.contains(key)) + merged[key] = payload[key]; + } + if (payload.contains("positions")) + merged["positions"] = payload["positions"]; + + const std::string site_id = StringUtil::trimCopy(merged.value("site_id", "")); + const std::string map_id = StringUtil::trimCopy(merged.value("map_id", "")); + const std::string name = StringUtil::trimCopy(merged.value("name", "")); + const auto positions = normalizePositions(merged.value("positions", nlohmann::json::array()), err); + if (!positions) + return false; + + if (site_id.empty() || map_id.empty() || name.empty()) + { + err = "missing required fields"; + return false; + } + + std::lock_guard lock(mu_); + if (findNameConflictUnlocked(site_id, name, id)) + { + err = "path guide name already exists for this site"; + return false; + } + + const std::string now = IdUtil::nowIso8601(); + const std::string positions_json = positions->dump(); + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "UPDATE path_guides SET site_id=?2, map_id=?3, name=?4, positions_json=?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, site_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, positions_json.c_str(), -1, SQLITE_TRANSIENT); + 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 PathGuideStore::remove(const std::string& id, std::string& err) +{ + if (!find(id)) + { + err = "path guide not found"; + return false; + } + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM path_guides 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; + if (!ok) + err = sqlite3_errmsg(db_.handle()); + sqlite3_finalize(stmt); + return ok; +} + +} // namespace lm diff --git a/src/storage/path_guide_store.hpp b/src/storage/path_guide_store.hpp new file mode 100644 index 0000000..e5a02a5 --- /dev/null +++ b/src/storage/path_guide_store.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include +#include + +namespace lm { + +class Database; + +class PathGuideStore +{ +public: + explicit PathGuideStore(Database& db); + + nlohmann::json list(const std::string& site_id = "") const; + std::optional find(const std::string& id) const; + std::optional 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); + +private: + Database& db_; + mutable std::mutex mu_; + + bool findNameConflictUnlocked(const std::string& site_id, + const std::string& name, + const std::string& except_id) const; +}; + +} // namespace lm diff --git a/www/app.js b/www/app.js index dcb5ce1..e290f8e 100644 --- a/www/app.js +++ b/www/app.js @@ -16,6 +16,7 @@ const pageUsersEl = el("pageUsers"); const pageUserGroupsEl = el("pageUserGroups"); const pageIoModulesEl = el("pageIoModules"); const pagePathsEl = el("pagePaths"); +const pagePathGuidesEl = el("pagePathGuides"); const pageMonitoringEl = el("pageMonitoring"); const pageHelpEl = el("pageHelp"); const contentEl = document.querySelector(".content"); @@ -130,7 +131,7 @@ const state = { }; function setActivePage(page) { - const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "users", "integrations", "monitoring", "help"]; + const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "integrations", "monitoring", "help"]; let p = valid.includes(page) ? page : "missions"; if (window.AuthApp && !window.AuthApp.canAccessPage(p)) { const fallback = valid.find((v) => window.AuthApp.canAccessPage(v)); @@ -146,6 +147,7 @@ function setActivePage(page) { if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups"; if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules"; if (pagePathsEl) pagePathsEl.hidden = p !== "paths"; + if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides"; if (pageUsersEl) pageUsersEl.hidden = p !== "users"; if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring"; @@ -162,6 +164,7 @@ function setActivePage(page) { contentEl.classList.toggle("content--user-groups", p === "user-groups"); contentEl.classList.toggle("content--io-modules", p === "io-modules"); contentEl.classList.toggle("content--paths", p === "paths"); + contentEl.classList.toggle("content--path-guides", p === "path-guides"); contentEl.classList.toggle("content--users", p === "users"); contentEl.classList.toggle("content--integrations", p === "integrations"); contentEl.classList.toggle("content--monitoring", p === "monitoring"); @@ -180,6 +183,8 @@ function setActivePage(page) { else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide(); if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow(); else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide(); + if (p === "path-guides" && window.PathGuidesApp) window.PathGuidesApp.onPageShow(); + else if (window.PathGuidesApp?.onPageHide) window.PathGuidesApp.onPageHide(); if (p === "users" && window.UsersApp) window.UsersApp.onPageShow(); else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide(); if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow(); diff --git a/www/auth.js b/www/auth.js index 869a9b8..cbc431e 100644 --- a/www/auth.js +++ b/www/auth.js @@ -154,6 +154,7 @@ "user-groups": "users", "io-modules": "integrations", paths: "maps", + "path-guides": "maps", users: "users", integrations: "integrations", }; @@ -179,6 +180,7 @@ document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users")); document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations")); document.body.classList.toggle("auth-readonly-paths", !canWrite("maps")); + document.body.classList.toggle("auth-readonly-path-guides", !canWrite("maps")); } function updateUserMenu() { diff --git a/www/i18n.js b/www/i18n.js index f388a1c..42723fb 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -77,6 +77,7 @@ "nav.user-groups": "User groups", "nav.io-modules": "I/O modules", "nav.paths": "Paths", + "nav.path-guides": "Path guides", "nav.users": "Users", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", @@ -714,6 +715,60 @@ "paths.deleteTitle": "Xóa path?", "paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position này.", + "pathGuides.title": "Path guides", + "pathGuides.subtitle": "Định nghĩa tuyến cố định giữa các position qua waypoint.", + "pathGuides.helpTitle": "Trợ giúp Path guides", + "pathGuides.helpBody": "Path guide buộc robot đi theo chuỗi start → waypoint → goal trên map. Tạo position trên map editor trước, sau đó thêm start, waypoint (có thứ tự) và goal. Robot tự dùng path guide khi mission nhắm tới các position đó (Phase D).", + "pathGuides.create": "Tạo path guide", + "pathGuides.clearFilters": "Xóa bộ lọc", + "pathGuides.filterLabel": "Lọc:", + "pathGuides.filterPlaceholder": "Lọc theo tên hoặc map...", + "pathGuides.itemsFound": "{n} mục", + "pathGuides.pageOf": "Trang {page} / {total}", + "pathGuides.colName": "Tên", + "pathGuides.colMap": "Map", + "pathGuides.colStarts": "Starts", + "pathGuides.colVias": "Waypoints", + "pathGuides.colGoals": "Goals", + "pathGuides.colFunctions": "Functions", + "pathGuides.empty": "Chưa có path guide. Tạo waypoint trên map trước, sau đó thêm path guide.", + "pathGuides.emptyFilter": "Không có path guide khớp bộ lọc.", + "pathGuides.createTitle": "Tạo path guide", + "pathGuides.editTitle": "Sửa path guide", + "pathGuides.name": "Tên", + "pathGuides.site": "Site", + "pathGuides.map": "Map", + "pathGuides.mapHint": "Tạo position trên map editor trước khi thêm start, waypoint và goal.", + "pathGuides.starts": "Start positions", + "pathGuides.vias": "Waypoints (có thứ tự)", + "pathGuides.goals": "Goal positions", + "pathGuides.addStart": "Thêm start", + "pathGuides.addVia": "Thêm waypoint", + "pathGuides.addGoal": "Thêm goal", + "pathGuides.selectPosition": "Chọn position…", + "pathGuides.noStarts": "Chưa có start", + "pathGuides.noVias": "Chưa có waypoint", + "pathGuides.noGoals": "Chưa có goal", + "pathGuides.moveUp": "Lên", + "pathGuides.moveDown": "Xuống", + "pathGuides.deleteTitle": "Xóa path guide?", + "pathGuides.deleteConfirmText": "Xóa path guide \"{name}\" trên map \"{map}\"?", + "pathGuides.error.missing": "Thiếu tên, site hoặc map.", + "pathGuides.error.positions": "Cần ít nhất một start và một goal.", + "pathGuides.error.noPositions": "Không có position nào trên map này. Tạo position trong map editor trước.", + "pathGuides.dragHandle": "Kéo để đổi thứ tự", + "pathGuides.createPage.title": "Tạo path guide", + "pathGuides.createPage.subtitle": "Nhập tên và chọn map cho path guide.", + "pathGuides.createPage.goBack": "Quay lại", + "pathGuides.createPage.namePlaceholder": "Nhập tên path guide...", + "pathGuides.createPage.continue": "Tiếp tục", + "pathGuides.editPage.title": "Sửa vị trí path guide", + "pathGuides.editPage.subtitle": "Chỉnh start, waypoint và goal của path guide.", + "pathGuides.editPage.helpTitle": "Trợ giúp vị trí path guide", + "pathGuides.editPage.helpBody": "Thêm start, waypoint (kéo để đổi thứ tự) và goal. Robot sẽ đi theo chuỗi này khi mission dùng các position tương ứng.", + "pathGuides.editPage.goBack": "Quay lại", + "pathGuides.editPage.meta": "{name} — {map}", + "missions.title": "Missions", "missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.", "missions.create": "Tạo mission", @@ -910,6 +965,7 @@ "nav.user-groups": "User groups", "nav.io-modules": "I/O modules", "nav.paths": "Paths", + "nav.path-guides": "Path guides", "nav.users": "Users", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", @@ -1547,6 +1603,60 @@ "paths.deleteTitle": "Delete path?", "paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.", + "pathGuides.title": "Path guides", + "pathGuides.subtitle": "Define fixed routes between positions using waypoints.", + "pathGuides.helpTitle": "Path guides help", + "pathGuides.helpBody": "A path guide forces the robot to follow a start → waypoint → goal chain on the map. Create positions in the map editor first, then add starts, ordered waypoints, and goals. The robot will use the guide automatically when missions target those positions (planner integration in a later phase).", + "pathGuides.create": "Create path guide", + "pathGuides.clearFilters": "Clear filters", + "pathGuides.filterLabel": "Filter:", + "pathGuides.filterPlaceholder": "Filter by name or map...", + "pathGuides.itemsFound": "{n} item(s) found", + "pathGuides.pageOf": "Page {page} of {total}", + "pathGuides.colName": "Name", + "pathGuides.colMap": "Map", + "pathGuides.colStarts": "Starts", + "pathGuides.colVias": "Waypoints", + "pathGuides.colGoals": "Goals", + "pathGuides.colFunctions": "Functions", + "pathGuides.empty": "No path guides yet. Create waypoint positions on the map first, then add a path guide.", + "pathGuides.emptyFilter": "No path guides match the filter.", + "pathGuides.createTitle": "Create path guide", + "pathGuides.editTitle": "Edit path guide", + "pathGuides.name": "Name", + "pathGuides.site": "Site", + "pathGuides.map": "Map", + "pathGuides.mapHint": "Create positions on the map editor before adding starts, waypoints, and goals.", + "pathGuides.starts": "Start positions", + "pathGuides.vias": "Waypoints (ordered)", + "pathGuides.goals": "Goal positions", + "pathGuides.addStart": "Add start", + "pathGuides.addVia": "Add waypoint", + "pathGuides.addGoal": "Add goal", + "pathGuides.selectPosition": "Select position…", + "pathGuides.noStarts": "No starts yet", + "pathGuides.noVias": "No waypoints yet", + "pathGuides.noGoals": "No goals yet", + "pathGuides.moveUp": "Move up", + "pathGuides.moveDown": "Move down", + "pathGuides.deleteTitle": "Delete path guide?", + "pathGuides.deleteConfirmText": "Delete path guide \"{name}\" on map \"{map}\"?", + "pathGuides.error.missing": "Name, site, and map are required.", + "pathGuides.error.positions": "At least one start and one goal are required.", + "pathGuides.error.noPositions": "No positions on this map. Create positions in the map editor first.", + "pathGuides.dragHandle": "Drag to reorder", + "pathGuides.createPage.title": "Create path guide", + "pathGuides.createPage.subtitle": "Enter a name and select the map for this path guide.", + "pathGuides.createPage.goBack": "Go back", + "pathGuides.createPage.namePlaceholder": "Enter path guide name...", + "pathGuides.createPage.continue": "Continue", + "pathGuides.editPage.title": "Edit path guide positions", + "pathGuides.editPage.subtitle": "Edit the path guide's positions.", + "pathGuides.editPage.helpTitle": "Path guide positions help", + "pathGuides.editPage.helpBody": "Add starts, waypoints (drag to reorder), and goals. The robot follows this chain when missions use the matching positions.", + "pathGuides.editPage.goBack": "Go back", + "pathGuides.editPage.meta": "{name} — {map}", + "missions.title": "Missions", "missions.subtitle": "Setup → Missions — robot task list.", "missions.create": "Create mission", diff --git a/www/index.html b/www/index.html index 13193dd..b97617a 100644 --- a/www/index.html +++ b/www/index.html @@ -1334,6 +1334,164 @@ + +