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:
@@ -20,6 +20,7 @@ add_imgui_app(graph_explorer
|
||||
views.cpp
|
||||
types_registry.cpp
|
||||
layout_store.cpp
|
||||
entity_ops.cpp
|
||||
# --- viz ---
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
|
||||
|
||||
+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
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
// Operaciones CRUD sobre operations.db (entities + relations) y deteccion
|
||||
// heuristica de tipo a partir de texto libre. Pensado para que la toolbar y
|
||||
// el menu contextual del viewport puedan modificar el grafo y luego pedir
|
||||
// reload (issue 0049g flow).
|
||||
//
|
||||
// Convencion edge labels: si el caller no pasa nombre de relacion, se usa
|
||||
// k_default_relation_name = "RELATED_TO". Los enrichers deben pasar siempre
|
||||
// un nombre semantico (ej: "EXTRACTED_FROM", "RESOLVES_TO", ...).
|
||||
|
||||
namespace ge {
|
||||
|
||||
constexpr const char* k_default_relation_name = "RELATED_TO";
|
||||
|
||||
enum DetectedType {
|
||||
DT_TEXT = 0,
|
||||
DT_EMAIL,
|
||||
DT_IP_ADDRESS,
|
||||
DT_URL,
|
||||
DT_DOMAIN,
|
||||
DT_PHONE,
|
||||
};
|
||||
|
||||
DetectedType detect_type(const char* text);
|
||||
const char* detected_type_name(DetectedType dt);
|
||||
|
||||
// Inserta una entidad nueva. Si type_ref es NULL/vacio se infiere via
|
||||
// detect_type(name). Genera un id unico ("<type>_<unix_ms>"). Devuelve el id
|
||||
// en out_id (caller-owned buffer >= 64). Retorna false si SQLite falla.
|
||||
bool entity_insert(const char* db_path,
|
||||
const char* name,
|
||||
const char* type_ref,
|
||||
char* out_id, size_t out_id_n);
|
||||
|
||||
bool entity_delete(const char* db_path, const char* id);
|
||||
|
||||
bool entity_update_type(const char* db_path, const char* id, const char* new_type);
|
||||
|
||||
// Duplica una entidad existente. Mismo type/metadata, sufijo "_copy" en id
|
||||
// y "(copia)" en name. Devuelve el nuevo id en out_id.
|
||||
bool entity_duplicate(const char* db_path, const char* id,
|
||||
char* out_id, size_t out_id_n);
|
||||
|
||||
// Inserta una relacion. Si name es NULL/vacio usa k_default_relation_name.
|
||||
bool relation_insert(const char* db_path,
|
||||
const char* from_id, const char* to_id,
|
||||
const char* name);
|
||||
|
||||
// Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada
|
||||
// carga del grafo (graph_sources usa FNV1a sobre id como user_data).
|
||||
struct EntityIndex {
|
||||
std::unordered_map<uint64_t, std::string> by_hash;
|
||||
};
|
||||
|
||||
// Escanea operations.db y rellena el indice. Reentrante (clear+repoblar).
|
||||
bool entity_index_build(const char* db_path, EntityIndex* idx);
|
||||
|
||||
// Resuelve user_data a sql id. NULL si no existe.
|
||||
const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data);
|
||||
|
||||
} // namespace ge
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: 0001
|
||||
title: Chat con Claude sobre el grafo
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Panel "Chat" dentro de graph_explorer que permita conversar con Claude
|
||||
(Anthropic API) usando el grafo activo como contexto. El agente debe poder:
|
||||
|
||||
- Leer el grafo (entidades, relaciones, metadata) via tool-use sobre operations.db.
|
||||
- Responder preguntas tipo: "muestrame los nodos relacionados con X", "que
|
||||
patrones ves en estas conexiones", "que falta investigar".
|
||||
- Proponer mutaciones (crear nodo, etiquetar relacion) que el usuario aprueba
|
||||
con un click antes de aplicarse.
|
||||
|
||||
## Alcance tecnico
|
||||
|
||||
- Cliente HTTP minimo (libcurl o WinHTTP) → POST a `https://api.anthropic.com/v1/messages`.
|
||||
- Modelo por defecto: `claude-sonnet-4-6` (revisar al implementar).
|
||||
- API key desde env var `ANTHROPIC_API_KEY` o `~/.fn_anthropic_key`.
|
||||
- Tool-use: definir tools `query_entities`, `query_relations`, `propose_node`,
|
||||
`propose_relation`. Las "propose_*" no mutan: insertan en una cola que el
|
||||
usuario revisa antes de aplicar.
|
||||
- Estado de conversacion en memoria (lista de messages). Persistencia opcional
|
||||
en `graph_explorer.db` tabla `chat_sessions`.
|
||||
- Streaming SSE para feedback en vivo (puede dejarse para v2 — primer hit
|
||||
bloqueante esta bien).
|
||||
|
||||
## Decisiones a tomar
|
||||
|
||||
- Renderizado de markdown en ImGui (TextWrapped basico vs lib externa).
|
||||
- Threading: bloqueante en hilo aparte → cola de mensajes → main thread lee.
|
||||
|
||||
## Trabajo previo
|
||||
|
||||
Ya existe en el registry `python/functions/agents/anthropic_chat_py_agents.py`
|
||||
para inspiracion (usa el SDK Python). En C++ usaremos HTTP directo — sin SDK.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Panel "Chat" dockeable.
|
||||
- Conversacion con tool-use sobre operations.db funciona.
|
||||
- Las mutaciones propuestas por el agente se confirman desde la UI antes de
|
||||
llegar a la BD.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: 0002
|
||||
title: Enricher GLiNER + GLiREL — emitir entidades/relaciones desde un nodo texto
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Right-click sobre un nodo de tipo `text` → "Run enricher → Extract entities
|
||||
(GLiNER+GLiREL)". El enricher procesa el texto del nodo y crea:
|
||||
|
||||
- Nuevas entidades (person, org, email, location, ...) con tipos detectados.
|
||||
- Relaciones entre el nodo origen y las nuevas, etiquetadas con `EXTRACTED_FROM`.
|
||||
- Relaciones entre las nuevas entidades cuando GLiREL las detecte, etiquetadas
|
||||
con el tipo predicho por el modelo (`employed_by`, `located_in`, ...).
|
||||
|
||||
## Trabajo previo en el registry
|
||||
|
||||
Ya existen las funciones Python:
|
||||
- `python/functions/extraction/gliner_extract_*` (varios)
|
||||
- `python/functions/extraction/glirel_extract_*`
|
||||
- Pipeline `extract_graph_hybrid_py_pipelines` (issue 0040 cerrado, ver commit
|
||||
1a353878) que ya hace exactamente esto sobre un texto y devuelve un grafo
|
||||
estructurado.
|
||||
|
||||
El analysis `analysis/retrieving_graphs/` lo usa en notebooks.
|
||||
|
||||
## Alcance tecnico (C++ side)
|
||||
|
||||
- Definir interfaz de enricher: `enricher_run(node_id, db_path) -> int n_added`.
|
||||
- Implementacion `enricher_gliner_glirel`:
|
||||
- Spawn `python/.venv/bin/python3` con un script wrapper que recibe el texto
|
||||
por stdin (JSON `{"text": "..."}`) y devuelve por stdout el grafo
|
||||
estructurado (JSON `{"entities": [...], "relations": [...]}`).
|
||||
- Wrapper Python en `projects/osint_graph/apps/graph_explorer/enrichers/gliner_glirel.py`.
|
||||
- C++ usa `popen` o `CreateProcess` segun plataforma.
|
||||
- Insertar las entidades nuevas (entity_insert) y relaciones (relation_insert)
|
||||
con etiquetas semanticas.
|
||||
- UI: spinner en el menu mientras corre (cold start del modelo ~5s).
|
||||
|
||||
## Riesgos / decisiones
|
||||
|
||||
- Modelos pesados → cold start lento. Considerar pre-cargar al primer uso.
|
||||
- Streaming de progreso desde Python via stderr line-by-line.
|
||||
- Fallback si el venv no existe: mostrar mensaje en el menu en vez de fallar.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Click derecho en nodo `text` → "Extract entities" muestra opcion.
|
||||
- Tras correr, el grafo se recarga con las nuevas entidades visibles y
|
||||
conectadas con `EXTRACTED_FROM`.
|
||||
- Las relaciones entre entidades extraidas llevan el tipo que GLiREL predice.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
id: 0003
|
||||
title: Enricher web — descargar URL/dominio y extraer texto
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Right-click sobre un nodo `url` o `domain` → "Run enricher → Fetch & extract
|
||||
text". Descarga el HTML, extrae el texto principal, crea un nodo `text`
|
||||
conectado al origen con relacion `FETCHED_FROM`.
|
||||
|
||||
Despues el usuario puede encadenar: sobre ese nodo `text`, ejecutar el enricher
|
||||
GLiNER+GLiREL (issue 0002) para extraer entidades.
|
||||
|
||||
## Alcance
|
||||
|
||||
- HTTP GET con timeout (libcurl o sys WinHTTP).
|
||||
- Extraccion de texto: regex/strip de tags simple en v1; v2 usa una lib
|
||||
(htmlparser2 / lexbor / boost.url + algo de heuristica).
|
||||
- User-agent identificativo, respeto de robots.txt opcional (out-of-scope v1).
|
||||
- Limite de tamaño descargable (1 MB) para evitar bloqueos.
|
||||
|
||||
## Modelo de etiquetado
|
||||
|
||||
- Nodo origen (url/domain) → arista `FETCHED_FROM` → nodo nuevo (text con
|
||||
metadata={fetched_at, status_code, content_type, length}).
|
||||
- Nombre del nodo text: titulo de la pagina (si <title> existe) o primeros
|
||||
120 caracteres del cuerpo.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Funciona contra una URL real (https con TLS).
|
||||
- Maneja errores (404, timeout, redirects basicos) sin tumbar la app.
|
||||
- El nodo creado es visible y el texto se puede consumir por el enricher
|
||||
GLiNER+GLiREL del issue 0002.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: 0004
|
||||
title: Vista tabla — entidades agrupadas por tipo
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Ventana "Table" dockeable con una tabla por cada tipo de entidad presente en el
|
||||
grafo. Filas = entidades. Columnas: id, name, status, updated_at, neighbors
|
||||
count. Clickar una fila selecciona el nodo en el viewport.
|
||||
|
||||
## UI
|
||||
|
||||
- ImGui::BeginTable con sorting + clipper para >10k filas.
|
||||
- Tabs en la cabecera de la ventana, una tab por type_ref ordenado alfabetico.
|
||||
- Selector global "show all types" que apila todos en una sola tabla.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Tabla escala a 10k entidades sin lag perceptible.
|
||||
- Click en fila selecciona nodo en viewport (mismo flujo que Inspector
|
||||
Selectable).
|
||||
- Filtro de busqueda por substring sobre name/id.
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "views.h"
|
||||
#include "types_registry.h"
|
||||
#include "layout_store.h"
|
||||
#include "entity_ops.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -63,6 +64,9 @@ static auto g_fps_timer = std::chrono::steady_clock::now();
|
||||
// Label policy
|
||||
static graph::LabelPolicy g_label_policy;
|
||||
|
||||
// Indice user_data -> sql id (rebuild en cada load).
|
||||
static ge::EntityIndex g_idx;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -145,6 +149,10 @@ static bool load_input() {
|
||||
}
|
||||
g_graph.update_bounds();
|
||||
|
||||
// Indice user_data -> sql id (para CRUD desde menu contextual).
|
||||
ge::entity_index_build(g_input.uri, &g_idx);
|
||||
g_app.input_db_path = g_input.uri ? g_input.uri : "";
|
||||
|
||||
// Cargar posiciones guardadas para este graph_hash
|
||||
g_graph_hash = ge::compute_graph_hash(g_input.uri);
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
@@ -234,6 +242,88 @@ static void update_fps() {
|
||||
g_last_frame = now;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Context menu callback (right-click sobre nodo)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) {
|
||||
g_app.ctx_node = node_idx;
|
||||
g_app.ctx_open_request = true;
|
||||
if (node_idx >= 0 && node_idx < g_graph.node_count) {
|
||||
const GraphNode& n = g_graph.nodes[node_idx];
|
||||
if (n.type_id < (uint16_t)g_graph.type_count && g_graph.types[n.type_id].name) {
|
||||
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
|
||||
g_graph.types[n.type_id].name);
|
||||
} else {
|
||||
g_app.ctx_new_type[0] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lista de tipos disponibles para "Change type" — se construye desde el grafo
|
||||
// activo. Si esta vacia, se usa una lista por defecto.
|
||||
static const char* k_default_types[] = {
|
||||
"text", "person", "organization", "email", "ip_address", "domain",
|
||||
"url", "phone", "crypto_wallet", "malware", "vulnerability",
|
||||
};
|
||||
constexpr int k_default_types_n = (int)(sizeof(k_default_types) / sizeof(k_default_types[0]));
|
||||
|
||||
static void render_context_menu() {
|
||||
if (g_app.ctx_open_request) {
|
||||
ImGui::OpenPopup("##node_ctx");
|
||||
g_app.ctx_open_request = false;
|
||||
}
|
||||
if (!ImGui::BeginPopup("##node_ctx")) return;
|
||||
|
||||
int idx = g_app.ctx_node;
|
||||
if (idx < 0 || idx >= g_graph.node_count) {
|
||||
ImGui::TextDisabled("(no node)");
|
||||
ImGui::EndPopup();
|
||||
return;
|
||||
}
|
||||
const GraphNode& n = g_graph.nodes[idx];
|
||||
const char* lbl = graph::graph_label(&g_graph, n.label_idx);
|
||||
ImGui::TextDisabled("%s", lbl && *lbl ? lbl : "(unnamed)");
|
||||
ImGui::Separator();
|
||||
|
||||
if (ImGui::BeginMenu("Change type")) {
|
||||
// Tipos del grafo actual
|
||||
for (int i = 0; i < g_graph.type_count; ++i) {
|
||||
const char* name = g_graph.types[i].name;
|
||||
if (!name) continue;
|
||||
if (ImGui::MenuItem(name)) {
|
||||
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", name);
|
||||
g_app.want_change_type = true;
|
||||
}
|
||||
}
|
||||
ImGui::Separator();
|
||||
// Defaults extra (por si no estan presentes en el grafo cargado)
|
||||
for (int i = 0; i < k_default_types_n; ++i) {
|
||||
if (ImGui::MenuItem(k_default_types[i])) {
|
||||
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
|
||||
k_default_types[i]);
|
||||
g_app.want_change_type = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
if (ImGui::MenuItem("Duplicate")) {
|
||||
g_app.want_duplicate_node = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Delete")) {
|
||||
g_app.want_delete_node = true;
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
if (ImGui::BeginMenu("Run enricher")) {
|
||||
ImGui::TextDisabled("(coming soon — issues 0001/0002/0003)");
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Label callback
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -249,6 +339,7 @@ static const char* get_label_cb(int node_idx, void* /*user*/) {
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static fn_ui::PanelToggle g_panels[] = {
|
||||
{"Viewport", nullptr, &g_app.panel_viewport},
|
||||
{"Legend", nullptr, &g_app.panel_legend},
|
||||
{"Inspector", nullptr, &g_app.panel_inspector},
|
||||
{"Stats", nullptr, &g_app.panel_stats},
|
||||
@@ -281,8 +372,29 @@ static void render() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toolbar superior — usa una ventana sin scroll y sin titulo
|
||||
// Dockspace host: ocupa el area de trabajo bajo la menubar y permite
|
||||
// que cualquier ventana (Viewport, Legend, Inspector, Stats, Table) se
|
||||
// arrastre a un lateral o pestañas dentro de la app.
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
{
|
||||
ImGui::SetNextWindowPos (vp->WorkPos);
|
||||
ImGui::SetNextWindowSize(vp->WorkSize);
|
||||
ImGui::SetNextWindowViewport(vp->ID);
|
||||
ImGuiWindowFlags hostFlags =
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDocking |
|
||||
ImGuiWindowFlags_NoSavedSettings;
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::Begin("##dock_host", nullptr, hostFlags);
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::DockSpace(ImGui::GetID("##dockspace"), ImVec2(0, 0),
|
||||
ImGuiDockNodeFlags_PassthruCentralNode);
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// Toolbar superior — usa una ventana sin scroll y sin titulo
|
||||
ImGui::SetNextWindowPos(vp->WorkPos);
|
||||
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, 44.0f));
|
||||
ImGui::Begin("##toolbar", nullptr,
|
||||
@@ -335,56 +447,130 @@ static void render() {
|
||||
load_input();
|
||||
}
|
||||
|
||||
// Main work area — viewport central, paneles laterales
|
||||
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y + 44.0f));
|
||||
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - 44.0f));
|
||||
ImGui::Begin("##main", nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings);
|
||||
|
||||
ImGui::Columns(3, "##cols", true);
|
||||
static bool s_cols_initialized = false;
|
||||
if (!s_cols_initialized) {
|
||||
ImGui::SetColumnWidth(0, 220.0f);
|
||||
ImGui::SetColumnWidth(1, vp->WorkSize.x - 220.0f - 320.0f);
|
||||
s_cols_initialized = true;
|
||||
}
|
||||
|
||||
// Col izq: Legend
|
||||
ge::views_legend(g_app);
|
||||
ImGui::NextColumn();
|
||||
|
||||
// Col centro: Viewport + force step + labels overlay
|
||||
run_force_step();
|
||||
|
||||
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0));
|
||||
|
||||
// La primera vez que el viewport se dibuja, el renderer existe — bind
|
||||
// del atlas (si tenemos uno).
|
||||
if (!g_atlas_bound && g_viewport.renderer) {
|
||||
if (g_atlas) {
|
||||
graph_renderer_set_icon_atlas(g_viewport.renderer,
|
||||
graph_icons_texture(g_atlas),
|
||||
graph_icons_uv_table(g_atlas),
|
||||
graph_icons_count(g_atlas));
|
||||
// ---- Mutaciones (add/delete/duplicate/change_type) ----
|
||||
auto reload_after_mutation = [&]() {
|
||||
graph::GraphLoadStats stats{};
|
||||
if (ge::reload_graph(g_input, &g_graph, &stats)) {
|
||||
ge::entity_index_build(g_input.uri, &g_idx);
|
||||
ge::views_reset_visibility(g_app);
|
||||
ge::views_apply_visibility(g_app);
|
||||
g_graph.update_bounds();
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
if (restored > 0) g_graph.update_bounds();
|
||||
g_atlas_bound = false;
|
||||
g_gpu_dirty = true;
|
||||
}
|
||||
g_atlas_bound = true;
|
||||
};
|
||||
|
||||
if (g_app.want_add_node && g_app.add_buf[0]) {
|
||||
char new_id[80];
|
||||
if (ge::entity_insert(g_app.input_db_path.c_str(), g_app.add_buf,
|
||||
/*type_ref=*/nullptr, new_id, sizeof(new_id))) {
|
||||
std::fprintf(stdout, "[graph_explorer] added entity %s\n", new_id);
|
||||
g_app.add_buf[0] = 0;
|
||||
reload_after_mutation();
|
||||
} else {
|
||||
std::fprintf(stderr, "[graph_explorer] add_entity failed\n");
|
||||
}
|
||||
g_app.want_add_node = false;
|
||||
}
|
||||
|
||||
if (g_app.labels_enabled) {
|
||||
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
|
||||
&get_label_cb, nullptr);
|
||||
}
|
||||
ImGui::NextColumn();
|
||||
auto ctx_id = [&]() -> const char* {
|
||||
if (g_app.ctx_node < 0 || g_app.ctx_node >= g_graph.node_count) return nullptr;
|
||||
return ge::entity_index_lookup(g_idx, g_graph.nodes[g_app.ctx_node].user_data);
|
||||
};
|
||||
|
||||
// Col der: Inspector + Stats
|
||||
if (g_app.want_delete_node) {
|
||||
if (const char* id = ctx_id()) {
|
||||
if (ge::entity_delete(g_app.input_db_path.c_str(), id)) {
|
||||
std::fprintf(stdout, "[graph_explorer] deleted entity %s\n", id);
|
||||
reload_after_mutation();
|
||||
}
|
||||
}
|
||||
g_app.want_delete_node = false;
|
||||
g_app.ctx_node = -1;
|
||||
}
|
||||
|
||||
if (g_app.want_duplicate_node) {
|
||||
if (const char* id = ctx_id()) {
|
||||
char new_id[80];
|
||||
if (ge::entity_duplicate(g_app.input_db_path.c_str(), id,
|
||||
new_id, sizeof(new_id))) {
|
||||
std::fprintf(stdout, "[graph_explorer] duplicated %s -> %s\n", id, new_id);
|
||||
reload_after_mutation();
|
||||
}
|
||||
}
|
||||
g_app.want_duplicate_node = false;
|
||||
}
|
||||
|
||||
if (g_app.want_change_type && g_app.ctx_new_type[0]) {
|
||||
if (const char* id = ctx_id()) {
|
||||
if (ge::entity_update_type(g_app.input_db_path.c_str(), id, g_app.ctx_new_type)) {
|
||||
std::fprintf(stdout, "[graph_explorer] %s -> type %s\n", id, g_app.ctx_new_type);
|
||||
reload_after_mutation();
|
||||
}
|
||||
}
|
||||
g_app.want_change_type = false;
|
||||
}
|
||||
|
||||
// Posiciones iniciales razonables; el usuario puede moverlas y se
|
||||
// persiste via imgui.ini.
|
||||
const float top = vp->WorkPos.y + 44.0f;
|
||||
const float W = vp->WorkSize.x;
|
||||
const float H = vp->WorkSize.y - 44.0f;
|
||||
const float lw = 240.0f; // Legend
|
||||
const float rw = 320.0f; // Inspector / Stats
|
||||
const float sh = H * 0.55f; // Inspector altura
|
||||
|
||||
// Viewport — ventana central
|
||||
if (g_app.panel_viewport) {
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + lw, top), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(W - lw - rw, H), ImGuiCond_FirstUseEver);
|
||||
if (ImGui::Begin("Viewport", &g_app.panel_viewport)) {
|
||||
run_force_step();
|
||||
|
||||
GraphViewportCallbacks vp_cb{};
|
||||
vp_cb.on_context_menu = &on_context_menu_cb;
|
||||
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb);
|
||||
render_context_menu();
|
||||
|
||||
// La primera vez que el viewport se dibuja, el renderer existe —
|
||||
// bind del atlas (si tenemos uno).
|
||||
if (!g_atlas_bound && g_viewport.renderer) {
|
||||
if (g_atlas) {
|
||||
graph_renderer_set_icon_atlas(g_viewport.renderer,
|
||||
graph_icons_texture(g_atlas),
|
||||
graph_icons_uv_table(g_atlas),
|
||||
graph_icons_count(g_atlas));
|
||||
}
|
||||
g_atlas_bound = true;
|
||||
}
|
||||
|
||||
if (g_app.labels_enabled) {
|
||||
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
|
||||
&get_label_cb, nullptr);
|
||||
}
|
||||
}
|
||||
ImGui::End();
|
||||
} else {
|
||||
// Sin ventana visible, igual avanzamos la simulacion para que al
|
||||
// reabrirla el grafo este actualizado.
|
||||
run_force_step();
|
||||
}
|
||||
|
||||
// Legend — izquierda
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, top), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(lw, H), ImGuiCond_FirstUseEver);
|
||||
ge::views_legend(g_app);
|
||||
|
||||
// Inspector / Stats — derecha (apilados)
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(rw, sh), ImGuiCond_FirstUseEver);
|
||||
ge::views_inspector(g_app);
|
||||
ge::views_stats(g_app);
|
||||
ImGui::NextColumn();
|
||||
|
||||
ImGui::Columns(1);
|
||||
ImGui::End();
|
||||
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top + sh), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver);
|
||||
ge::views_stats(g_app);
|
||||
|
||||
g_first_render = false;
|
||||
}
|
||||
@@ -433,6 +619,16 @@ int main(int argc, char** argv) {
|
||||
// SQLite store junto al exe.
|
||||
ge::layout_store_open("graph_explorer.db");
|
||||
|
||||
// Si no llego --input/positional, intentar operations.db en el cwd
|
||||
// (mismo criterio que graph_explorer.db: relativo al directorio de ejecucion).
|
||||
if (g_input_path.empty()) {
|
||||
if (FILE* f = std::fopen("operations.db", "rb")) {
|
||||
std::fclose(f);
|
||||
g_input_path = "operations.db";
|
||||
std::fprintf(stdout, "[graph_explorer] using default ./operations.db\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (!g_input_path.empty()) {
|
||||
load_input();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "views.h"
|
||||
#include "entity_ops.h"
|
||||
|
||||
#include "viz/graph_types.h"
|
||||
#include "viz/graph_viewport.h"
|
||||
@@ -93,6 +94,24 @@ void views_toolbar(AppState& app) {
|
||||
if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) {
|
||||
app.show_open_modal = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
|
||||
// Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo
|
||||
// confirman; main.cpp inserta en operations.db y dispara reload.
|
||||
ImGui::SetNextItemWidth(220);
|
||||
DetectedType dt = detect_type(app.add_buf);
|
||||
ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue;
|
||||
char hint[64];
|
||||
std::snprintf(hint, sizeof(hint), "Add node (%s)...", detected_type_name(dt));
|
||||
if (ImGui::InputTextWithHint("##addnode", hint, app.add_buf, sizeof(app.add_buf), flags)) {
|
||||
app.want_add_node = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (button(TI_PLUS " Add", ButtonVariant::Primary)) {
|
||||
app.want_add_node = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("[%s]", detected_type_name(dt));
|
||||
toolbar_separator();
|
||||
|
||||
ImGui::TextUnformatted("Layout:");
|
||||
@@ -268,12 +287,17 @@ void views_inspector(AppState& app) {
|
||||
for (int e = 0; e < g.edge_count && neighbor_count < 64; ++e) {
|
||||
const GraphEdge& edge = g.edges[e];
|
||||
int other = -1;
|
||||
if (edge.source == (uint32_t)idx) other = (int)edge.target;
|
||||
else if (edge.target == (uint32_t)idx) other = (int)edge.source;
|
||||
const char* arrow = " ";
|
||||
if (edge.source == (uint32_t)idx) { other = (int)edge.target; arrow = "->"; }
|
||||
else if (edge.target == (uint32_t)idx) { other = (int)edge.source; arrow = "<-"; }
|
||||
if (other < 0 || other >= g.node_count) continue;
|
||||
const char* olbl = graph::graph_label(&g, g.nodes[other].label_idx);
|
||||
const char* rname = (edge.type_id < (uint16_t)g.rel_type_count &&
|
||||
g.rel_types[edge.type_id].name)
|
||||
? g.rel_types[edge.type_id].name
|
||||
: k_default_relation_name;
|
||||
char buf[256];
|
||||
std::snprintf(buf, sizeof(buf), "[%d] %s", other,
|
||||
std::snprintf(buf, sizeof(buf), "%s %s [%d] %s", arrow, rname, other,
|
||||
olbl && *olbl ? olbl : "(unnamed)");
|
||||
if (ImGui::Selectable(buf)) {
|
||||
graph_viewport_clear_selection(g, *app.viewport);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
struct GraphData;
|
||||
struct GraphViewportState;
|
||||
|
||||
@@ -35,6 +37,7 @@ struct AppState {
|
||||
bool panel_legend = true;
|
||||
bool panel_inspector = true;
|
||||
bool panel_stats = true;
|
||||
bool panel_viewport = true;
|
||||
bool show_filters_modal = false;
|
||||
bool show_open_modal = false;
|
||||
|
||||
@@ -47,6 +50,24 @@ struct AppState {
|
||||
|
||||
// Labels overlay
|
||||
bool labels_enabled = true;
|
||||
|
||||
// Path activo de operations.db (para CRUD desde toolbar / contextmenu).
|
||||
// main.cpp lo escribe tras cargar y los handlers lo leen.
|
||||
std::string input_db_path;
|
||||
|
||||
// Add-node toolbar input.
|
||||
char add_buf[256] = {};
|
||||
|
||||
// Triggers de mutacion — main.cpp los procesa y dispara reload.
|
||||
bool want_add_node = false; // commit del input add_buf
|
||||
bool want_delete_node = false; // delete del nodo en ctx_node
|
||||
bool want_duplicate_node = false;
|
||||
bool want_change_type = false; // a ctx_new_type
|
||||
int ctx_node = -1; // node_idx objetivo
|
||||
char ctx_new_type[64] = {};
|
||||
|
||||
// Context menu state — popup global identificado por nombre.
|
||||
bool ctx_open_request = false; // se setea en on_context_menu
|
||||
};
|
||||
|
||||
// Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout).
|
||||
|
||||
Reference in New Issue
Block a user