Files
graph_explorer/project_manager.cpp
T
egutierrez fc4f0824da feat(0035a): tipo Group + columna group_id en entities
Plumbing para issue 0035 — agrupacion de resultados de enrichers
cuando exceden umbral. Sin cambios visibles para el usuario todavia.

- Migracion idempotente: ALTER TABLE entities ADD COLUMN group_id si
  no existe (detectado via PRAGMA table_info). Se ejecuta al abrir
  el proyecto en switch_to_project y en el bootstrap inicial.
- Tipo Group en examples/types.yaml (template) y en el types.yaml
  del proyecto default activo en Windows.
- shape=square (regla en types_registry.cpp extendida a Group),
  color=#94A3B8, icon=ti-stack-2.
- Fields: name (req), count (int), enricher (string), batch_id (string).

Refs: issues/0035a-group-type-and-schema.md
2026-05-03 14:23:23 +02:00

580 lines
19 KiB
C++

#include "project_manager.h"
#include "../../../../cpp/framework/app_base.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 {
// Helpers de paths — resuelven contra <exe_dir>/local_files/.
const char* projects_root() {
return fn::local_path(k_projects_subdir);
}
const char* settings_path() {
return fn::local_path(k_settings_basename);
}
// ----------------------------------------------------------------------------
// 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 '',
group_id TEXT,
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. Incluye
// `fields` (issue 0005) y el tipo Table como cuadrado (issue 0010 preview).
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.
# Tipos de campo: string | int | float | bool | date | url | enum.
entities:
- name: Person
color: "#5B8DEF"
icon: ti-user
principal_field: name
fields:
- { name: name, type: string, required: true }
- { name: first_name, type: string }
- { name: last_name, type: string }
- name: Email
color: "#58CA8C"
icon: ti-mail
principal_field: address
fields:
- { name: address, type: string, required: true }
- name: Domain
color: "#F4B860"
icon: ti-world
- name: Org
color: "#C780E8"
icon: ti-building
- name: Document
color: "#C9C9C9"
icon: ti-file
- name: Table
color: "#0EA5E9"
icon: ti-database
principal_field: name
fields:
- { name: name, type: string, required: true }
- { name: row_type, type: string }
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(projects_root()) / 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(projects_root(), ec);
return !ec;
}
bool project_list(std::vector<std::string>* out) {
if (!out) return false;
out->clear();
std::error_code ec;
if (!fs::exists(projects_root(), ec)) return true;
for (auto& e : fs::directory_iterator(projects_root(), 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;
}
// ----------------------------------------------------------------------------
// Migraciones idempotentes de operations.db existente
// ----------------------------------------------------------------------------
//
// Cada migracion es un check + ALTER TABLE / CREATE ... condicional. Se
// ejecuta una vez al abrir un proyecto (project_migrate_schema). Detecta el
// estado actual via PRAGMA table_info y ejecuta solo lo que falta. No-op si
// la BD ya esta al dia.
//
// Issue 0035a: anade columna entities.group_id (TEXT NULL) si no existe.
// Devuelve true si la columna `column` existe en la tabla `table`.
static bool table_has_column(sqlite3* db, const char* table, const char* column) {
std::string sql = "PRAGMA table_info(";
sql += table;
sql += ");";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
bool found = false;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* name = sqlite3_column_text(stmt, 1);
if (name && std::strcmp(reinterpret_cast<const char*>(name), column) == 0) {
found = true;
break;
}
}
sqlite3_finalize(stmt);
return found;
}
bool project_migrate_schema(const std::string& path, std::string* error_msg) {
sqlite3* db = nullptr;
int rc = sqlite3_open_v2(path.c_str(), &db,
SQLITE_OPEN_READWRITE, 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;
}
// 0035a: entities.group_id
if (!table_has_column(db, "entities", "group_id")) {
char* errmsg = nullptr;
rc = sqlite3_exec(db,
"ALTER TABLE entities ADD COLUMN group_id TEXT",
nullptr, nullptr, &errmsg);
if (rc != SQLITE_OK) {
if (error_msg) {
*error_msg = "ALTER TABLE entities ADD COLUMN group_id failed: ";
*error_msg += errmsg ? errmsg : "(unknown)";
}
if (errmsg) sqlite3_free(errmsg);
sqlite3_close(db);
return false;
}
if (errmsg) sqlite3_free(errmsg);
std::fprintf(stdout,
"[project_manager] migrated %s: ALTER TABLE entities ADD COLUMN group_id\n",
path.c_str());
}
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(projects_root(), 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(settings_path());
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(settings_path());
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