Files
graph_explorer/project_manager.cpp
T
egutierrez f0148ac368 fix(viz): forzar circulo a todos los nodos salvo Table; tapar energia inicial
- types_registry.cpp::apply_types_yaml: tras aplicar el yaml, sobreescribe
  shape de cada tipo: 'Table' → SHAPE_SQUARE, todo lo demas → SHAPE_CIRCLE.
  Convencion fija — ediciones futuras del Type Editor (issue 0007) o del
  yaml no rompen la regla.
- examples/types.yaml + project_manager.cpp seed: quitar campo `shape`,
  añadir tipo Table (cuadrado) y relacion CONTAINS_ROW (preview de 0010).
- main.cpp run_force_step: damping=0.7, max_velocity=8 explicitos para
  evitar que el grafo "explote" al cargar grafos pequenos.
- AppState repulsion: 1500 → 800 (lo mismo, aplicado al force layout).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:53:21 +02:00

492 lines
16 KiB
C++

#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.
# Todos los nodos son circulo, salvo "Table" que es cuadrado.
# Iconos: nombres ti-* mapeados en types_registry.cpp
# Estilos de relacion: solid | dashed | dotted
entities:
- name: Person
color: "#5B8DEF"
icon: ti-user
- name: Email
color: "#58CA8C"
icon: ti-mail
- name: Domain
color: "#F4B860"
icon: ti-world
- name: Phone
color: "#E36AC0"
icon: ti-phone
- name: Org
color: "#C780E8"
icon: ti-building
- name: Document
color: "#C9C9C9"
icon: ti-file
# Nodo tabla — cuadrado (issue 0010).
- name: Table
color: "#0EA5E9"
icon: ti-database
relations:
- name: owns
color: "#888888"
style: solid
- name: knows
color: "#AAAAAA"
style: solid
- name: located_in
color: "#FFB870"
style: dashed
- name: CONTAINS_ROW
color: "#0EA5E9"
style: dotted
)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