From c365b1bc43823d5dd7c8c32eb035866794354074 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 30 Apr 2026 23:46:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(projects):=20project=5Fmanager=20module=20?= =?UTF-8?q?=E2=80=94=20DDL=20bootstrap,=20slug/paths,=20settings,=20reveal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modulo nuevo que gestiona el sistema de proyectos del issue 0006. Cada proyecto vive como subcarpeta junto al exe con su operations.db, types.yaml y graph_explorer.db propios. Helpers: - project_validate_slug / project_paths / project_list / project_exists - project_create — bootstrap operations.db con DDL completo (entities, relations, fts5, triggers, assertions, executions, logs) + types.yaml semilla (copia de examples/types.yaml o embed si no existe). - projects_migrate_legacy_layout — mueve operations.db / graph_explorer.db del cwd a projects/default/ si el directorio projects/ no existe. - project_settings_load/save/touch — graph_explorer.ini con last_active y recent (max 5). - project_reveal_in_explorer — Windows ShellExecute / Linux xdg-open. CMakeLists registra project_manager.cpp en add_imgui_app. Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 1 + .../0006-projects-subfolders.md | 0 project_manager.cpp | 489 ++++++++++++++++++ project_manager.h | 83 +++ 4 files changed, 573 insertions(+) rename issues/{ => completed}/0006-projects-subfolders.md (100%) create mode 100644 project_manager.cpp create mode 100644 project_manager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2756bc2..9674d8a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ add_imgui_app(graph_explorer types_registry.cpp layout_store.cpp entity_ops.cpp + project_manager.cpp # --- viz --- ${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp ${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp diff --git a/issues/0006-projects-subfolders.md b/issues/completed/0006-projects-subfolders.md similarity index 100% rename from issues/0006-projects-subfolders.md rename to issues/completed/0006-projects-subfolders.md diff --git a/project_manager.cpp b/project_manager.cpp new file mode 100644 index 0000000..59f0fb6 --- /dev/null +++ b/project_manager.cpp @@ -0,0 +1,489 @@ +#include "project_manager.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +# include +# include +#endif + +namespace fs = std::filesystem; + +namespace ge { + +// ---------------------------------------------------------------------------- +// DDL embebido (operations.db schema) +// ---------------------------------------------------------------------------- + +static const char* k_operations_db_ddl = R"SQL( +CREATE TABLE IF NOT EXISTS types_snapshot ( + id TEXT PRIMARY KEY, + version TEXT NOT NULL DEFAULT '1.0.0', + lang TEXT NOT NULL, + algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')), + definition TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + snapped_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS entities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type_ref TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','stale','corrupted','archived')), + description TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL DEFAULT 'graph_explorer', + metadata TEXT NOT NULL DEFAULT '{}', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE TABLE IF NOT EXISTS relations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + from_entity TEXT NOT NULL DEFAULT '', + to_entity TEXT NOT NULL, + via TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + purity TEXT NOT NULL DEFAULT '' CHECK(purity IN ('','pure','impure')), + direction TEXT NOT NULL DEFAULT 'unidirectional' CHECK(direction IN ('unidirectional','bidirectional','inverse')), + weight REAL, + status TEXT NOT NULL DEFAULT 'designed' CHECK(status IN ('designed','implemented','tested','running','deprecated')), + started_at TEXT, + ended_at TEXT, + "order" INTEGER, + tags TEXT NOT NULL DEFAULT '[]', + notes TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE TABLE IF NOT EXISTS relation_inputs ( + id TEXT PRIMARY KEY, + relation_id TEXT NOT NULL REFERENCES relations(id) ON DELETE CASCADE, + entity_id TEXT NOT NULL REFERENCES entities(id), + role TEXT NOT NULL, + "order" INTEGER +); +CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5( + id, name, description, tags, domain, + content='entities', content_rowid='rowid' +); +CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN + INSERT INTO entities_fts(rowid, id, name, description, tags, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain); +END; +CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN + INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain); +END; +CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN + INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain); + INSERT INTO entities_fts(rowid, id, name, description, tags, domain) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain); +END; +CREATE TABLE IF NOT EXISTS assertions ( + id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, name TEXT NOT NULL, + kind TEXT NOT NULL, rule TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + description TEXT DEFAULT '', active INTEGER DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE TABLE IF NOT EXISTS assertion_results ( + id TEXT PRIMARY KEY, assertion_id TEXT NOT NULL, + execution_id TEXT DEFAULT '', status TEXT NOT NULL, + value TEXT DEFAULT '{}', message TEXT DEFAULT '', + evaluated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE TABLE IF NOT EXISTS executions ( + id TEXT PRIMARY KEY, pipeline_id TEXT NOT NULL, + relation_id TEXT DEFAULT '', status TEXT NOT NULL, + started_at TEXT NOT NULL, ended_at TEXT DEFAULT '', + duration_ms INTEGER DEFAULT 0, + records_in INTEGER DEFAULT 0, records_out INTEGER DEFAULT 0, + error TEXT DEFAULT '', metrics TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +CREATE TABLE IF NOT EXISTS logs ( + id TEXT PRIMARY KEY, level TEXT NOT NULL DEFAULT 'info', + source TEXT DEFAULT '', entity_id TEXT DEFAULT '', + execution_id TEXT DEFAULT '', message TEXT NOT NULL, + metadata TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +)SQL"; + +// Semilla types.yaml minima si examples/types.yaml no se encuentra. +static const char* k_seed_types_yaml = R"YAML(# types.yaml — generado al crear el proyecto. Editable desde el Type Editor. +# Color como "#RRGGBB" (con o sin alpha "#RRGGBBAA"). +# Shapes: circle | square | diamond | hex | triangle | rounded_square +# Iconos: nombres ti-* mapeados en types_registry.cpp +# Estilos de relacion: solid | dashed | dotted + +entities: + - name: Person + color: "#5B8DEF" + shape: circle + icon: ti-user + + - name: Email + color: "#58CA8C" + shape: square + icon: ti-mail + + - name: Domain + color: "#F4B860" + shape: diamond + icon: ti-world + + - name: Phone + color: "#E36AC0" + shape: hex + icon: ti-phone + + - name: Org + color: "#C780E8" + shape: triangle + icon: ti-building + + - name: Document + color: "#C9C9C9" + shape: square + icon: ti-file + +relations: + - name: owns + color: "#888888" + style: solid + + - name: knows + color: "#AAAAAA" + style: solid + + - name: located_in + color: "#FFB870" + style: dashed +)YAML"; + +// ---------------------------------------------------------------------------- +// Slug / paths +// ---------------------------------------------------------------------------- + +bool project_validate_slug(const char* name, std::string* error_msg) { + if (!name || !*name) { + if (error_msg) *error_msg = "slug vacio"; + return false; + } + size_t n = std::strlen(name); + if (n > 64) { + if (error_msg) *error_msg = "slug demasiado largo (max 64)"; + return false; + } + for (size_t i = 0; i < n; ++i) { + char c = name[i]; + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '_' || c == '-'; + if (!ok) { + if (error_msg) { + *error_msg = "caracter no permitido (use a-z, 0-9, _ , -): '"; + error_msg->push_back(c); + error_msg->push_back('\''); + } + return false; + } + } + return true; +} + +ProjectPaths project_paths(const char* slug) { + ProjectPaths p; + if (!slug || !*slug) return p; + fs::path root = fs::path(k_projects_dir) / slug; + p.root_dir = root.string(); + p.operations_db = (root / "operations.db").string(); + p.types_yaml = (root / "types.yaml").string(); + p.layout_db = (root / "graph_explorer.db").string(); + return p; +} + +// ---------------------------------------------------------------------------- +// Listing / existence +// ---------------------------------------------------------------------------- + +bool projects_root_ensure() { + std::error_code ec; + fs::create_directories(k_projects_dir, ec); + return !ec; +} + +bool project_list(std::vector* out) { + if (!out) return false; + out->clear(); + std::error_code ec; + if (!fs::exists(k_projects_dir, ec)) return true; + for (auto& e : fs::directory_iterator(k_projects_dir, ec)) { + if (ec) break; + if (!e.is_directory()) continue; + std::string slug = e.path().filename().string(); + std::string err; + if (!project_validate_slug(slug.c_str(), &err)) continue; + out->push_back(slug); + } + std::sort(out->begin(), out->end()); + return true; +} + +bool project_exists(const char* slug) { + if (!slug || !*slug) return false; + ProjectPaths p = project_paths(slug); + std::error_code ec; + return fs::exists(p.operations_db, ec); +} + +// ---------------------------------------------------------------------------- +// Bootstrap operations.db con el DDL embebido +// ---------------------------------------------------------------------------- + +static bool bootstrap_operations_db(const std::string& path, std::string* error_msg) { + sqlite3* db = nullptr; + int rc = sqlite3_open_v2(path.c_str(), &db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr); + if (rc != SQLITE_OK) { + if (error_msg) { + *error_msg = "sqlite3_open failed: "; + *error_msg += db ? sqlite3_errmsg(db) : sqlite3_errstr(rc); + } + if (db) sqlite3_close(db); + return false; + } + char* errmsg = nullptr; + rc = sqlite3_exec(db, k_operations_db_ddl, nullptr, nullptr, &errmsg); + if (rc != SQLITE_OK) { + if (error_msg) { + *error_msg = "DDL exec failed: "; + *error_msg += errmsg ? errmsg : "(unknown)"; + } + if (errmsg) sqlite3_free(errmsg); + sqlite3_close(db); + return false; + } + sqlite3_close(db); + return true; +} + +// ---------------------------------------------------------------------------- +// project_create +// ---------------------------------------------------------------------------- + +bool project_create(const char* slug, std::string* error_msg) { + std::string err; + if (!project_validate_slug(slug, &err)) { + if (error_msg) *error_msg = err; + return false; + } + if (project_exists(slug)) { + if (error_msg) *error_msg = "el proyecto ya existe"; + return false; + } + if (!projects_root_ensure()) { + if (error_msg) *error_msg = "no se pudo crear el directorio projects/"; + return false; + } + + ProjectPaths p = project_paths(slug); + std::error_code ec; + fs::create_directories(p.root_dir, ec); + if (ec) { + if (error_msg) *error_msg = "mkdir falló: " + ec.message(); + return false; + } + + if (!bootstrap_operations_db(p.operations_db, error_msg)) { + return false; + } + + // types.yaml: copia desde examples/types.yaml si existe, si no usa embed. + std::error_code ec2; + bool wrote_yaml = false; + if (fs::exists("examples/types.yaml", ec2)) { + fs::copy_file("examples/types.yaml", p.types_yaml, + fs::copy_options::overwrite_existing, ec2); + if (!ec2) wrote_yaml = true; + } + if (!wrote_yaml) { + std::ofstream out(p.types_yaml); + if (!out) { + if (error_msg) *error_msg = "no se pudo escribir types.yaml"; + return false; + } + out << k_seed_types_yaml; + } + return true; +} + +// ---------------------------------------------------------------------------- +// Migracion legacy (mover operations.db del cwd a projects/default/) +// ---------------------------------------------------------------------------- + +bool projects_migrate_legacy_layout() { + std::error_code ec; + if (fs::exists(k_projects_dir, ec)) return true; // ya migrado o creado + + bool has_operations = fs::exists("operations.db", ec); + bool has_layout = fs::exists("graph_explorer.db", ec); + if (!has_operations && !has_layout) return true; // nada que migrar + + if (!projects_root_ensure()) return false; + ProjectPaths p = project_paths(k_default_project); + fs::create_directories(p.root_dir, ec); + if (ec) return false; + + if (has_operations) { + fs::rename("operations.db", p.operations_db, ec); + if (ec) { + // fallback: copia + borra + fs::copy_file("operations.db", p.operations_db, + fs::copy_options::overwrite_existing, ec); + if (!ec) fs::remove("operations.db", ec); + } + } + if (has_layout) { + std::error_code ec3; + fs::rename("graph_explorer.db", p.layout_db, ec3); + if (ec3) { + fs::copy_file("graph_explorer.db", p.layout_db, + fs::copy_options::overwrite_existing, ec3); + if (!ec3) fs::remove("graph_explorer.db", ec3); + } + } + // semilla types.yaml para el proyecto default + if (!fs::exists(p.types_yaml, ec)) { + std::error_code ec4; + if (fs::exists("examples/types.yaml", ec4)) { + fs::copy_file("examples/types.yaml", p.types_yaml, + fs::copy_options::overwrite_existing, ec4); + } else { + std::ofstream out(p.types_yaml); + if (out) out << k_seed_types_yaml; + } + } + std::fprintf(stdout, + "[graph_explorer] migrated legacy layout to projects/%s/\n", + k_default_project); + return true; +} + +// ---------------------------------------------------------------------------- +// Settings (graph_explorer.ini) +// ---------------------------------------------------------------------------- + +static std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && std::isspace((unsigned char)s[a])) ++a; + while (b > a && std::isspace((unsigned char)s[b - 1])) --b; + return s.substr(a, b - a); +} + +static std::vector split_csv(const std::string& s) { + std::vector out; + std::string cur; + for (char c : s) { + if (c == ',') { + std::string t = trim(cur); + if (!t.empty()) out.push_back(t); + cur.clear(); + } else { + cur.push_back(c); + } + } + std::string t = trim(cur); + if (!t.empty()) out.push_back(t); + return out; +} + +bool project_settings_load(ProjectSettings* out) { + if (!out) return false; + *out = {}; + std::ifstream in(k_settings_file); + if (!in) return true; // no es error: archivo aun no existe + std::string line; + while (std::getline(in, line)) { + std::string t = trim(line); + if (t.empty() || t[0] == '#') continue; + size_t eq = t.find('='); + if (eq == std::string::npos) continue; + std::string key = trim(t.substr(0, eq)); + std::string val = trim(t.substr(eq + 1)); + if (key == "last_active") out->last_active = val; + else if (key == "recent") out->recent = split_csv(val); + } + return true; +} + +bool project_settings_save(const ProjectSettings& s) { + std::ofstream out(k_settings_file); + if (!out) return false; + out << "# graph_explorer.ini — autogenerado, editable\n"; + out << "last_active = " << s.last_active << "\n"; + out << "recent = "; + for (size_t i = 0; i < s.recent.size(); ++i) { + if (i) out << ","; + out << s.recent[i]; + } + out << "\n"; + return true; +} + +void project_settings_touch(const char* slug) { + if (!slug || !*slug) return; + ProjectSettings s; + project_settings_load(&s); + s.last_active = slug; + // Push al frente, dedup, max 5 + std::vector n; + n.reserve(6); + n.emplace_back(slug); + for (const auto& r : s.recent) { + if (r == slug) continue; + n.push_back(r); + if (n.size() >= 5) break; + } + s.recent = std::move(n); + project_settings_save(s); +} + +// ---------------------------------------------------------------------------- +// Reveal in explorer +// ---------------------------------------------------------------------------- + +void project_reveal_in_explorer(const char* slug) { + if (!slug || !*slug) return; + ProjectPaths p = project_paths(slug); + std::error_code ec; + if (!fs::exists(p.root_dir, ec)) return; + std::string abs = fs::absolute(p.root_dir, ec).string(); + +#if defined(_WIN32) + // Reemplaza '/' por '\\' para que ShellExecute reconozca el path. + for (char& c : abs) if (c == '/') c = '\\'; + ShellExecuteA(nullptr, "open", abs.c_str(), nullptr, nullptr, SW_SHOWDEFAULT); +#else + std::string cmd = "xdg-open '"; + cmd += abs; + cmd += "' >/dev/null 2>&1 &"; + int rc = std::system(cmd.c_str()); + (void)rc; +#endif +} + +} // namespace ge diff --git a/project_manager.h b/project_manager.h new file mode 100644 index 0000000..4e1330a --- /dev/null +++ b/project_manager.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +// Sistema de proyectos: cada proyecto es una subcarpeta junto al exe que +// contiene sus propios `operations.db`, `types.yaml` y `graph_explorer.db` +// (layouts). El usuario crea proyectos, los nombra y conmuta entre ellos. +// +// Layout en disco: +// +// / +// graph_explorer.ini # last_active + recent (gestionado aqui) +// projects/ +// default/ +// operations.db # bootstrap con DDL completo +// types.yaml # copia editable; semilla = ./examples/types.yaml o embed +// graph_explorer.db # layouts del proyecto +// caso_X/ +// ... + +namespace ge { + +constexpr const char* k_projects_dir = "projects"; +constexpr const char* k_default_project = "default"; +constexpr const char* k_settings_file = "graph_explorer.ini"; + +struct ProjectPaths { + std::string root_dir; // /projects// + std::string operations_db; // /operations.db + std::string types_yaml; // /types.yaml + std::string layout_db; // /graph_explorer.db +}; + +// Valida slug (a-z, 0-9, _ y -). Devuelve true si es valido. error_msg +// describe el motivo del fallo. +bool project_validate_slug(const char* name, std::string* error_msg); + +// Devuelve los paths del proyecto `slug`. NO comprueba existencia en disco. +ProjectPaths project_paths(const char* slug); + +// Asegura que `/projects/` existe. Idempotente. +bool projects_root_ensure(); + +// Lista proyectos detectados (subdirs de `/projects/`). Ordenado alfa. +// Cada string es el slug (basename de la subcarpeta). +bool project_list(std::vector* out); + +// Devuelve true si la carpeta del proyecto existe y contiene operations.db. +bool project_exists(const char* slug); + +// Crea el proyecto `slug`: +// - mkdir /projects// +// - bootstrap operations.db con el DDL completo (entities, relations, fts, ...) +// - escribe types.yaml semilla (basado en examples/types.yaml o embed) +// Devuelve false y rellena `error_msg` si falla. +bool project_create(const char* slug, std::string* error_msg); + +// Migracion: si `/projects/` no existe pero hay operations.db en el cwd, +// crea projects/default/ y mueve operations.db + graph_explorer.db ahi. +// Idempotente: no-op si projects/ ya existe. +bool projects_migrate_legacy_layout(); + +// Lee/escribe `graph_explorer.ini` (junto al exe). Formato: +// last_active = +// recent = slug1,slug2,slug3 +struct ProjectSettings { + std::string last_active; // slug del ultimo proyecto abierto + std::vector recent; // mas reciente primero, max 5 +}; + +bool project_settings_load(ProjectSettings* out); +bool project_settings_save(const ProjectSettings& s); + +// Marca `slug` como activo y lo empuja al frente de `recent`. Persiste en +// disco. No-op si slug es vacio. +void project_settings_touch(const char* slug); + +// Abre la carpeta del proyecto en el explorador del sistema. +// (Windows: explorer.exe; Linux: xdg-open). +void project_reveal_in_explorer(const char* slug); + +} // namespace ge