#include "project_manager.h" #include #include #include #include #include #include #include #include #include #if defined(_WIN32) # include # include #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* 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 split_csv(const std::string& s) { std::vector 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 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