fb7538088b
- examples/types.yaml: principal_field + fields para Person, Email,
Domain, Phone, Org, IBAN, Account, Document, Address, Url, Table.
44 fields totales. Documentacion del formato en cabecera.
- project_manager.cpp: seed con fields para los tipos basicos (fallback
cuando no se encuentra examples/types.yaml).
- main.cpp:
- Log de carga incluye conteo de schemas y total de fields.
- --test-types-yaml <path>: smoke test que carga, serializa a temp y
recarga. Compara entidades/relaciones/fields field-a-field. Salida
PASS/FAIL con exit code 0/1. Permite verificar round-trip sin
framework de tests.
Verificado: examples/types.yaml round-trip estable (11 entities, 44
fields, 6 relations).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
499 lines
17 KiB
C++
499 lines
17 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. 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(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
|