feat(projects): project_manager module — DDL bootstrap, slug/paths, settings, reveal

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 23:46:35 +02:00
parent 491161204e
commit c365b1bc43
4 changed files with 573 additions and 0 deletions
+489
View File
@@ -0,0 +1,489 @@
#include "project_manager.h"
#include <sqlite3.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#if defined(_WIN32)
# include <windows.h>
# include <shellapi.h>
#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<std::string>* 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<std::string> split_csv(const std::string& s) {
std::vector<std::string> 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<std::string> 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