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:
@@ -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
|
||||
Reference in New Issue
Block a user