feat: docking host + add-node toolbar + node context menu
- Dockspace host (PassthruCentralNode) bajo la toolbar para que las
ventanas Viewport/Legend/Inspector/Stats puedan dockearse dentro de la
app principal.
- Toolbar: input "Add node" con auto-deteccion de tipo (text/email/
ip_address/url/domain/phone). Insert en operations.db + reload.
- Context menu (right-click sobre nodo): Change type, Duplicate, Delete,
submenu "Run enricher" (placeholder hasta issues 0001-0003).
- Inspector: vecinos ahora muestran etiqueta de relacion ("-> employs",
"<- owns") usando rel_types[].name como label de arista.
- Default relation label k_default_relation_name="RELATED_TO" para
relaciones creadas sin nombre semantico explicito.
- Indice EntityIndex (FNV1a hash -> sql id) reconstruido tras cada load
para resolver mutaciones desde el grafo en memoria.
Issues planteadas para iteraciones siguientes:
- 0001: chat con Claude sobre el grafo (HTTP + tool-use)
- 0002: enricher GLiNER+GLiREL desde nodo texto
- 0003: enricher web (fetch URL/dominio + extract text)
- 0004: vista tabla por tipo de entidad
This commit is contained in:
+322
@@ -0,0 +1,322 @@
|
||||
#include "entity_ops.h"
|
||||
|
||||
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <cctype>
|
||||
|
||||
namespace ge {
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// FNV1a-64 — debe coincidir con graph_sources.cpp
|
||||
// ----------------------------------------------------------------------------
|
||||
static uint64_t fnv1a64(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (; s && *s; ++s) {
|
||||
h ^= (uint8_t)*s;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Heuristicas — sin <regex> para evitar peso. Inspeccion lineal del string.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static bool is_email(const char* s) {
|
||||
if (!s || !*s) return false;
|
||||
const char* at = std::strchr(s, '@');
|
||||
if (!at || at == s) return false;
|
||||
if (std::strchr(at + 1, '@')) return false; // dos @
|
||||
const char* dot = std::strchr(at + 1, '.');
|
||||
if (!dot || dot[1] == 0) return false;
|
||||
if (std::strchr(s, ' ')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool is_ipv4(const char* s) {
|
||||
if (!s || !*s) return false;
|
||||
int parts = 0, digits = 0, n = 0;
|
||||
for (const char* p = s; ; ++p) {
|
||||
if (*p >= '0' && *p <= '9') {
|
||||
n = n * 10 + (*p - '0');
|
||||
if (++digits > 3 || n > 255) return false;
|
||||
} else if (*p == '.' || *p == 0) {
|
||||
if (digits == 0) return false;
|
||||
++parts; digits = 0; n = 0;
|
||||
if (*p == 0) break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return parts == 4;
|
||||
}
|
||||
|
||||
static bool is_url(const char* s) {
|
||||
if (!s) return false;
|
||||
return std::strncmp(s, "http://", 7) == 0 || std::strncmp(s, "https://", 8) == 0;
|
||||
}
|
||||
|
||||
static bool is_domain(const char* s) {
|
||||
if (!s || !*s) return false;
|
||||
if (std::strchr(s, ' ') || std::strchr(s, '@') || std::strchr(s, '/')) return false;
|
||||
const char* dot = std::strchr(s, '.');
|
||||
if (!dot || dot == s || dot[1] == 0) return false;
|
||||
// El TLD debe ser al menos 2 caracteres alfabeticos
|
||||
int tld = 0;
|
||||
for (const char* p = std::strrchr(s, '.') + 1; *p; ++p) {
|
||||
if (!std::isalpha((unsigned char)*p)) return false;
|
||||
++tld;
|
||||
}
|
||||
return tld >= 2;
|
||||
}
|
||||
|
||||
static bool is_phone(const char* s) {
|
||||
if (!s || !*s) return false;
|
||||
int digits = 0;
|
||||
for (const char* p = s; *p; ++p) {
|
||||
if (*p >= '0' && *p <= '9') ++digits;
|
||||
else if (*p == '+' || *p == ' ' || *p == '-' || *p == '(' || *p == ')' || *p == '.') {}
|
||||
else return false;
|
||||
}
|
||||
return digits >= 7 && digits <= 15;
|
||||
}
|
||||
|
||||
DetectedType detect_type(const char* text) {
|
||||
if (!text || !*text) return DT_TEXT;
|
||||
if (is_email(text)) return DT_EMAIL;
|
||||
if (is_ipv4(text)) return DT_IP_ADDRESS;
|
||||
if (is_url(text)) return DT_URL;
|
||||
if (is_phone(text)) return DT_PHONE; // antes que domain (numeros con puntos)
|
||||
if (is_domain(text)) return DT_DOMAIN;
|
||||
return DT_TEXT;
|
||||
}
|
||||
|
||||
const char* detected_type_name(DetectedType dt) {
|
||||
switch (dt) {
|
||||
case DT_EMAIL: return "email";
|
||||
case DT_IP_ADDRESS: return "ip_address";
|
||||
case DT_URL: return "url";
|
||||
case DT_DOMAIN: return "domain";
|
||||
case DT_PHONE: return "phone";
|
||||
case DT_TEXT:
|
||||
default: return "text";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SQLite helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static bool exec_one(sqlite3* db, const char* sql,
|
||||
const char** params, int n_params)
|
||||
{
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) return false;
|
||||
for (int i = 0; i < n_params; ++i) {
|
||||
sqlite3_bind_text(st, i + 1, params[i] ? params[i] : "", -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
int rc = sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
return rc == SQLITE_DONE;
|
||||
}
|
||||
|
||||
static std::string now_iso() {
|
||||
using namespace std::chrono;
|
||||
auto t = system_clock::to_time_t(system_clock::now());
|
||||
char buf[32];
|
||||
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t));
|
||||
return std::string(buf) + "Z";
|
||||
}
|
||||
|
||||
static long long now_ms() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool entity_insert(const char* db_path, const char* name, const char* type_ref,
|
||||
char* out_id, size_t out_id_n)
|
||||
{
|
||||
if (!db_path || !name || !*name || !out_id || out_id_n < 32) return false;
|
||||
|
||||
std::string tref;
|
||||
if (type_ref && *type_ref) {
|
||||
tref = type_ref;
|
||||
} else {
|
||||
tref = detected_type_name(detect_type(name));
|
||||
}
|
||||
|
||||
std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms());
|
||||
std::string ts = now_iso();
|
||||
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
const char* sql =
|
||||
"INSERT INTO entities (id, name, type_ref, source, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, 'manual', ?, ?)";
|
||||
const char* params[5] = { out_id, name, tref.c_str(), ts.c_str(), ts.c_str() };
|
||||
bool ok = exec_one(db, sql, params, 5);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool entity_delete(const char* db_path, const char* id) {
|
||||
if (!db_path || !id) return false;
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
const char* p_rel[2] = { id, id };
|
||||
const char* p_ent[1] = { id };
|
||||
exec_one(db, "DELETE FROM relations WHERE from_entity = ? OR to_entity = ?", p_rel, 2);
|
||||
bool ok = exec_one(db, "DELETE FROM entities WHERE id = ?", p_ent, 1);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool entity_update_type(const char* db_path, const char* id, const char* new_type) {
|
||||
if (!db_path || !id || !new_type) return false;
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
std::string ts = now_iso();
|
||||
const char* p[3] = { new_type, ts.c_str(), id };
|
||||
bool ok = exec_one(db, "UPDATE entities SET type_ref = ?, updated_at = ? WHERE id = ?", p, 3);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool entity_duplicate(const char* db_path, const char* id,
|
||||
char* out_id, size_t out_id_n)
|
||||
{
|
||||
if (!db_path || !id || !out_id || out_id_n < 32) return false;
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_stmt* st = nullptr;
|
||||
const char* sel = "SELECT name, type_ref, description, domain, tags, source, metadata, notes "
|
||||
"FROM entities WHERE id = ?";
|
||||
if (sqlite3_prepare_v2(db, sel, -1, &st, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(st) != SQLITE_ROW) {
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
auto col = [&](int i) {
|
||||
const unsigned char* p = sqlite3_column_text(st, i);
|
||||
return std::string(p ? (const char*)p : "");
|
||||
};
|
||||
std::string name = col(0) + " (copia)";
|
||||
std::string tref = col(1);
|
||||
std::string desc = col(2);
|
||||
std::string dom = col(3);
|
||||
std::string tags = col(4);
|
||||
std::string src = col(5);
|
||||
std::string meta = col(6);
|
||||
std::string notes = col(7);
|
||||
sqlite3_finalize(st);
|
||||
|
||||
std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms());
|
||||
std::string ts = now_iso();
|
||||
|
||||
const char* ins =
|
||||
"INSERT INTO entities (id, name, type_ref, description, domain, tags, source, "
|
||||
"metadata, notes, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
sqlite3_stmt* ist = nullptr;
|
||||
if (sqlite3_prepare_v2(db, ins, -1, &ist, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(ist, 1, out_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 2, name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 3, tref.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 4, desc.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 5, dom.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 6, tags.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 7, src.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 8, meta.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 9, notes.c_str(),-1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 10, ts.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ist, 11, ts.c_str(), -1, SQLITE_TRANSIENT);
|
||||
bool ok = sqlite3_step(ist) == SQLITE_DONE;
|
||||
sqlite3_finalize(ist);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool relation_insert(const char* db_path, const char* from_id, const char* to_id,
|
||||
const char* name)
|
||||
{
|
||||
if (!db_path || !from_id || !to_id) return false;
|
||||
const char* rel = (name && *name) ? name : k_default_relation_name;
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
char id[64];
|
||||
std::snprintf(id, sizeof(id), "rel_%lld", now_ms());
|
||||
std::string ts = now_iso();
|
||||
const char* sql =
|
||||
"INSERT INTO relations (id, name, from_entity, to_entity, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)";
|
||||
const char* params[6] = { id, rel, from_id, to_id, ts.c_str(), ts.c_str() };
|
||||
bool ok = exec_one(db, sql, params, 6);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Index user_data -> sql id
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool entity_index_build(const char* db_path, EntityIndex* idx) {
|
||||
if (!db_path || !idx) return false;
|
||||
idx->by_hash.clear();
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, "SELECT id FROM entities", -1, &st, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const unsigned char* p = sqlite3_column_text(st, 0);
|
||||
if (!p) continue;
|
||||
const char* id = (const char*)p;
|
||||
idx->by_hash.emplace(fnv1a64(id), std::string(id));
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data) {
|
||||
auto it = idx.by_hash.find(user_data);
|
||||
return (it == idx.by_hash.end()) ? nullptr : it->second.c_str();
|
||||
}
|
||||
|
||||
} // namespace ge
|
||||
Reference in New Issue
Block a user