merge: issue/0005-type-schema-fields — schema de fields en types.yaml

Parser+writer para fields (string/int/float/bool/date/url/enum),
principal_field y round-trip estable. Semilla con 44 fields en
11 tipos. CLI --test-types-yaml para verificar round-trip.

Cierra issue 0005.
This commit is contained in:
2026-05-01 00:01:02 +02:00
6 changed files with 483 additions and 54 deletions
+74 -5
View File
@@ -1,57 +1,126 @@
# Ejemplo OSINT — tipos comunes en investigacion de entidades.
# Color como "#RRGGBB" (con o sin alpha "#RRGGBBAA").
# Shapes: el campo `shape` se ignora — todos los nodos son circulo, salvo
# el tipo "Table" que es cuadrado (regla de forma aplicada en
# types_registry.cpp::apply_types_yaml).
# Iconos: nombres ti-* mapeados en types_registry.cpp::tabler_codepoint_by_name
# Estilos de relacion: solid | dashed | dotted
# Shapes: campo `shape` ignorado — todos los nodos son circulo, salvo
# el tipo "Table" que es cuadrado (regla en types_registry.cpp::apply_types_yaml).
# Iconos: nombres ti-* mapeados en tabler_codepoint_by_name.
# Fields: schema por tipo, formato inline-map.
# tipos: string | int | float | bool | date | url | enum
# enum requiere `values: [...]`
# `principal_field` (opcional) es el nombre del field que sirve de label
# visible en el grafo; default = "name".
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: age, type: int }
- { name: nationality, type: string }
- { name: birth_date, type: date }
- { name: gender, type: enum, values: [male, female, other] }
- { name: occupation, type: string }
- name: Email
color: "#58CA8C"
icon: ti-mail
principal_field: address
fields:
- { name: address, type: string, required: true }
- { name: provider, type: string }
- { name: verified, type: bool }
- name: Domain
color: "#F4B860"
icon: ti-world
principal_field: name
fields:
- { name: name, type: string, required: true }
- { name: registrar, type: string }
- { name: created_at, type: date }
- { name: expires_at, type: date }
- name: Phone
color: "#E36AC0"
icon: ti-phone
principal_field: number
fields:
- { name: number, type: string, required: true }
- { name: country_code, type: string }
- { name: kind, type: enum, values: [mobile, landline, voip] }
- name: Org
color: "#C780E8"
icon: ti-building
principal_field: name
fields:
- { name: name, type: string, required: true }
- { name: country, type: string }
- { name: kind, type: enum, values: [company, ngo, gov, other] }
- { name: website, type: url }
- name: IBAN
color: "#52CDF2"
icon: ti-building-bank
principal_field: iban
fields:
- { name: iban, type: string, required: true }
- { name: country, type: string }
- { name: bank_name, type: string }
- name: Account
color: "#7FD3A0"
icon: ti-id
principal_field: handle
fields:
- { name: handle, type: string, required: true }
- { name: platform, type: string }
- { name: url, type: url }
- { name: verified, type: bool }
- name: Document
color: "#C9C9C9"
icon: ti-file
principal_field: title
fields:
- { name: title, type: string, required: true }
- { name: url, type: url }
- { name: kind, type: string }
- { name: created, type: date }
- name: Address
color: "#FFB870"
icon: ti-map-pin
principal_field: line1
fields:
- { name: line1, type: string, required: true }
- { name: city, type: string }
- { name: country, type: string }
- { name: postcode, type: string }
- name: Url
color: "#89E0FC"
icon: ti-link
principal_field: url
fields:
- { name: url, type: url, required: true }
- { name: title, type: string }
- { name: domain, type: string }
# Nodo tabla — cuadrado (regla de forma). Issue 0010: contenedor con
# filas que son nodos del grafo.
- name: Table
color: "#0EA5E9"
icon: ti-database
principal_field: name
fields:
- { name: name, type: string, required: true }
- { name: row_type, type: string }
- { name: columns, type: string }
- { name: expanded, type: bool }
relations:
- name: owns
@@ -1,9 +1,10 @@
---
id: 0005
title: Type schema en types.yaml — fields por tipo + principal_field
status: pending
status: completed
priority: high
created: 2026-04-30
completed: 2026-05-01
---
## Objetivo
+81 -3
View File
@@ -189,9 +189,17 @@ static bool load_input() {
g_atlas_bound = false;
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(codepoints);
int total_fields = 0;
int with_schema = 0;
for (const auto& e : pt.entities) {
total_fields += (int)e.fields.size();
if (!e.fields.empty()) ++with_schema;
}
std::fprintf(stdout,
"[graph_explorer] types.yaml: %zu entities, %zu relations, %zu icons\n",
pt.entities.size(), pt.relations.size(), codepoints.size());
"[graph_explorer] types.yaml: %zu entities (%d con schema, %d fields totales),"
" %zu relations, %zu icons\n",
pt.entities.size(), with_schema, total_fields,
pt.relations.size(), codepoints.size());
}
}
@@ -765,7 +773,75 @@ static void usage() {
" graph_explorer --input operations <path>\n"
" graph_explorer --types <types.yaml>\n"
" graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n"
" graph_explorer --project <slug>\n");
" graph_explorer --project <slug>\n"
" graph_explorer --test-types-yaml <path> (load+save+reload smoke test)\n");
}
// Smoke test del parser+writer (issue 0005 round-trip): carga `path`,
// serializa a un temporal y vuelve a cargar. Compara campos clave de
// ParsedTypes. Devuelve exit code 0 si OK, 1 si discrepancia, 2 si error.
static int test_types_yaml_roundtrip(const char* path) {
ge::ParsedTypes pt1;
std::string err;
if (!ge::types_load_yaml(path, &pt1, &err)) {
std::fprintf(stderr, "[test] load1 fail: %s\n", err.c_str());
return 2;
}
std::string tmp = std::string(path) + ".roundtrip.yaml";
if (!ge::types_save_yaml(tmp.c_str(), pt1, &err)) {
std::fprintf(stderr, "[test] save fail: %s\n", err.c_str());
return 2;
}
ge::ParsedTypes pt2;
if (!ge::types_load_yaml(tmp.c_str(), &pt2, &err)) {
std::fprintf(stderr, "[test] load2 fail: %s\n", err.c_str());
return 2;
}
auto cmp = [&]() -> bool {
if (pt1.entities.size() != pt2.entities.size()) return false;
if (pt1.relations.size() != pt2.relations.size()) return false;
for (size_t i = 0; i < pt1.entities.size(); ++i) {
const auto& a = pt1.entities[i];
const auto& b = pt2.entities[i];
if (a.name != b.name) return false;
if (a.color != b.color) return false;
if (a.icon_name != b.icon_name) return false;
if (a.principal_field != b.principal_field) return false;
if (a.fields.size() != b.fields.size()) return false;
for (size_t j = 0; j < a.fields.size(); ++j) {
const auto& fa = a.fields[j];
const auto& fb = b.fields[j];
if (fa.name != fb.name) return false;
if (fa.kind != fb.kind) return false;
if (fa.required != fb.required) return false;
if (fa.enum_values != fb.enum_values) return false;
}
}
for (size_t i = 0; i < pt1.relations.size(); ++i) {
const auto& a = pt1.relations[i];
const auto& b = pt2.relations[i];
if (a.name != b.name) return false;
if (a.color != b.color) return false;
if (a.style != b.style) return false;
}
return true;
};
int total_fields = 0;
for (const auto& e : pt1.entities) total_fields += (int)e.fields.size();
if (cmp()) {
std::fprintf(stdout,
"[test] PASS — %zu entities, %d fields, %zu relations (round-trip estable)\n",
pt1.entities.size(), total_fields, pt1.relations.size());
std::remove(tmp.c_str());
return 0;
}
std::fprintf(stderr,
"[test] FAIL — discrepancia tras round-trip. dump preservado en %s\n",
tmp.c_str());
return 1;
}
int main(int argc, char** argv) {
@@ -790,6 +866,8 @@ int main(int argc, char** argv) {
g_layout_initial = argv[++i];
} else if (std::strcmp(a, "--project") == 0 && i + 1 < argc) {
project_arg = argv[++i];
} else if (std::strcmp(a, "--test-types-yaml") == 0 && i + 1 < argc) {
return test_types_yaml_roundtrip(argv[++i]);
} else if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
usage();
return 0;
+15 -8
View File
@@ -123,29 +123,33 @@ CREATE TABLE IF NOT EXISTS logs (
);
)SQL";
// Semilla types.yaml minima si examples/types.yaml no se encuentra.
// 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.
# Iconos: nombres ti-* mapeados en types_registry.cpp
# Estilos de relacion: solid | dashed | dotted
# 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: Phone
color: "#E36AC0"
icon: ti-phone
- name: Org
color: "#C780E8"
icon: ti-building
@@ -154,10 +158,13 @@ entities:
color: "#C9C9C9"
icon: ti-file
# Nodo tabla — cuadrado (issue 0010).
- 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
+271 -35
View File
@@ -101,6 +101,74 @@ uint8_t parse_style(const std::string& v) {
return EDGE_USE_TYPE;
}
// ----------------------------------------------------------------------------
// Inline-map parser: { key: value, key: [a, b, c], key: value }
// ----------------------------------------------------------------------------
// Split a string by ',' respecting [...] brackets. Devuelve los segmentos
// trimeados.
std::vector<std::string> split_top_level_commas(const std::string& s) {
std::vector<std::string> out;
int depth = 0;
std::string cur;
for (char c : s) {
if (c == '[' || c == '{') ++depth;
else if (c == ']' || c == '}') --depth;
if (c == ',' && depth == 0) {
out.push_back(trim(cur));
cur.clear();
} else {
cur.push_back(c);
}
}
std::string t = trim(cur);
if (!t.empty()) out.push_back(t);
return out;
}
// Parse "[a, b, c]" → {"a","b","c"}. Soporta strings sin/con comillas.
std::vector<std::string> parse_inline_array(const std::string& s) {
std::vector<std::string> out;
std::string body = trim(s);
if (body.size() < 2 || body.front() != '[' || body.back() != ']') return out;
body = body.substr(1, body.size() - 2);
for (auto& tok : split_top_level_commas(body)) {
std::string v = strip_quotes(trim(tok));
if (!v.empty()) out.push_back(v);
}
return out;
}
// Parse "{ name: x, type: y, required: true, values: [a,b,c] }" → FieldSpec.
// Devuelve true si parseo algo (al menos un name no vacio).
bool parse_field_inline_map(const std::string& raw, FieldSpec* out) {
if (!out) return false;
std::string body = trim(raw);
if (body.size() < 2 || body.front() != '{' || body.back() != '}') return false;
body = body.substr(1, body.size() - 2);
*out = FieldSpec{};
for (auto& pair : split_top_level_commas(body)) {
auto colon = pair.find(':');
if (colon == std::string::npos) continue;
std::string k = trim(pair.substr(0, colon));
std::string v = trim(pair.substr(colon + 1));
if (k == "name") {
out->name = strip_quotes(v);
} else if (k == "type") {
out->kind = field_kind_from_name(strip_quotes(v).c_str());
} else if (k == "required") {
std::string lv = strip_quotes(v);
std::transform(lv.begin(), lv.end(), lv.begin(),
[](unsigned char c) { return std::tolower(c); });
out->required = (lv == "true" || lv == "1" || lv == "yes");
} else if (k == "values") {
out->enum_values = parse_inline_array(v);
}
}
return !out->name.empty();
}
// Cuenta el numero de espacios al principio de una linea. Tabs cuentan como
// 4 espacios para que el archivo siga siendo legible si alguien usa tabs.
int leading_indent(const std::string& line) {
@@ -120,6 +188,36 @@ bool starts_with(const std::string& s, const char* p) {
} // namespace
const char* field_kind_name(FieldKind k) {
switch (k) {
case FK_STRING: return "string";
case FK_INT: return "int";
case FK_FLOAT: return "float";
case FK_BOOL: return "bool";
case FK_DATE: return "date";
case FK_URL: return "url";
case FK_ENUM: return "enum";
}
return "string";
}
FieldKind field_kind_from_name(const char* s) {
if (!s || !*s) return FK_STRING;
std::string v = s;
std::transform(v.begin(), v.end(), v.begin(),
[](unsigned char c) { return std::tolower(c); });
if (v == "string" || v == "str" || v == "text") return FK_STRING;
if (v == "int" || v == "integer") return FK_INT;
if (v == "float" || v == "double") return FK_FLOAT;
if (v == "bool" || v == "boolean") return FK_BOOL;
if (v == "date") return FK_DATE;
if (v == "url" || v == "uri") return FK_URL;
if (v == "enum") return FK_ENUM;
std::fprintf(stderr,
"[types.yaml] tipo de campo desconocido '%s' → tratado como string\n", s);
return FK_STRING;
}
bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg) {
if (!out) return false;
std::ifstream f(path);
@@ -133,7 +231,9 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
EntitySpec cur_entity;
RelationSpec cur_rel;
bool have_item = false;
bool have_item = false;
int item_indent = -1; // indent de "- " que abre el item
bool in_fields = false; // dentro de `fields:` del entity actual
auto flush = [&]() {
if (!have_item) return;
@@ -145,6 +245,46 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
cur_entity = EntitySpec{};
cur_rel = RelationSpec{};
have_item = false;
in_fields = false;
item_indent = -1;
};
// Aplica un sub-key "key: value" al item actual. Si es `fields` con
// valor vacio, activa modo fields.
auto apply_subkey = [&](const std::string& k, const std::string& v) {
if (k == "name") {
if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v);
else cur_rel.name = strip_quotes(v);
} else if (k == "color") {
uint32_t c = parse_color_hex(v);
if (section == SEC_ENTITIES) cur_entity.color = c;
else cur_rel.color = c;
} else if (k == "shape" && section == SEC_ENTITIES) {
cur_entity.shape = parse_shape(v);
} else if (k == "icon" && section == SEC_ENTITIES) {
std::string nm = strip_quotes(v);
cur_entity.icon_name = nm;
cur_entity.icon_cp = tabler_codepoint_by_name(nm.c_str());
} else if (k == "principal_field" && section == SEC_ENTITIES) {
cur_entity.principal_field = strip_quotes(v);
} else if (k == "fields" && section == SEC_ENTITIES) {
// Sub-seccion: si el valor es vacio entramos en modo fields y
// las siguientes lineas "- { ... }" se parsean como FieldSpec.
// Si trae lista inline ("[...]") la parseamos aqui (raro pero
// soportado).
std::string vv = trim(v);
if (vv.empty()) {
in_fields = true;
} else if (!vv.empty() && vv.front() == '[') {
// No soportado en v1: lista inline de inline-maps en una
// misma linea. Se ignora con warning.
std::fprintf(stderr,
"[types.yaml] fields: en linea con [...] no soportado;"
" usar lista multilinea con `- { ... }`\n");
}
} else if (k == "style" && section == SEC_RELATIONS) {
cur_rel.style = parse_style(v);
}
};
std::string line;
@@ -186,32 +326,37 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
if (section == SEC_NONE) continue;
// Inicio de item: "- name: Foo" o "-"
if (starts_with(trimmed, "- ")) {
flush();
have_item = true;
// Item nuevo a nivel del seccion (indent <= item_indent o sin item):
// disambigua "- " entre apertura de item y entrada de fields.
bool is_dash = starts_with(trimmed, "- ");
bool is_field_line = in_fields && is_dash && indent > item_indent;
bool is_new_item = is_dash && !is_field_line;
if (is_field_line) {
// Parse "- { ... }" como FieldSpec
std::string body = trim(trimmed.substr(2));
FieldSpec fs;
if (parse_field_inline_map(body, &fs)) {
cur_entity.fields.push_back(std::move(fs));
} else {
std::fprintf(stderr,
"[types.yaml] field invalido (esperado inline map "
"{ name: ..., type: ... }): %s\n", body.c_str());
}
continue;
}
if (is_new_item) {
flush();
have_item = true;
item_indent = indent;
in_fields = false;
std::string body = trim(trimmed.substr(2));
// Si la linea trae un par key:value tras el guion, parsearlo aqui
auto colon = body.find(':');
if (colon != std::string::npos) {
std::string k = trim(body.substr(0, colon));
std::string v = trim(body.substr(colon + 1));
if (k == "name") {
if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v);
else cur_rel.name = strip_quotes(v);
}
// Cualquier otra key inline se reasigna mas abajo si hay sub-lineas
else if (k == "color") {
uint32_t c = parse_color_hex(v);
if (section == SEC_ENTITIES) cur_entity.color = c;
else cur_rel.color = c;
} else if (k == "shape" && section == SEC_ENTITIES) {
cur_entity.shape = parse_shape(v);
} else if (k == "icon" && section == SEC_ENTITIES) {
cur_entity.icon_cp = tabler_codepoint_by_name(strip_quotes(v).c_str());
} else if (k == "style" && section == SEC_RELATIONS) {
cur_rel.style = parse_style(v);
}
apply_subkey(k, v);
}
continue;
}
@@ -222,26 +367,117 @@ bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg)
if (colon == std::string::npos) continue;
std::string k = trim(trimmed.substr(0, colon));
std::string v = trim(trimmed.substr(colon + 1));
if (k == "name") {
if (section == SEC_ENTITIES) cur_entity.name = strip_quotes(v);
else cur_rel.name = strip_quotes(v);
} else if (k == "color") {
uint32_t c = parse_color_hex(v);
if (section == SEC_ENTITIES) cur_entity.color = c;
else cur_rel.color = c;
} else if (k == "shape" && section == SEC_ENTITIES) {
cur_entity.shape = parse_shape(v);
} else if (k == "icon" && section == SEC_ENTITIES) {
cur_entity.icon_cp = tabler_codepoint_by_name(strip_quotes(v).c_str());
} else if (k == "style" && section == SEC_RELATIONS) {
cur_rel.style = parse_style(v);
// Sub-key del entity con indent menor o igual al item: significa
// que salimos de in_fields (el indent de fields_items es mayor).
if (in_fields && indent <= item_indent + 2) {
in_fields = false;
}
apply_subkey(k, v);
}
}
flush();
return true;
}
// ----------------------------------------------------------------------------
// Writer: ParsedTypes -> types.yaml
// ----------------------------------------------------------------------------
namespace {
// ABGR -> "#RRGGBB" (o "#RRGGBBAA" si alpha != 0xFF). Devuelve "" si color es 0.
std::string color_to_hex(uint32_t c) {
if (c == 0) return "";
uint8_t r = (uint8_t)( c & 0xFF);
uint8_t g = (uint8_t)((c >> 8) & 0xFF);
uint8_t b = (uint8_t)((c >> 16) & 0xFF);
uint8_t a = (uint8_t)((c >> 24) & 0xFF);
char buf[16];
if (a == 0xFF) {
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b);
} else {
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X%02X", r, g, b, a);
}
return buf;
}
const char* style_name(uint8_t s) {
switch (s) {
case EDGE_SOLID: return "solid";
case EDGE_DASHED: return "dashed";
case EDGE_DOTTED: return "dotted";
default: return "";
}
}
// Serializa un FieldSpec como inline-map: "{ name: X, type: Y, required: true, values: [a,b,c] }".
std::string serialize_field(const FieldSpec& f) {
std::string out = "{ name: ";
out += f.name;
out += ", type: ";
out += field_kind_name(f.kind);
if (f.required) out += ", required: true";
if (f.kind == FK_ENUM && !f.enum_values.empty()) {
out += ", values: [";
for (size_t i = 0; i < f.enum_values.size(); ++i) {
if (i) out += ", ";
out += f.enum_values[i];
}
out += "]";
}
out += " }";
return out;
}
} // namespace
bool types_save_yaml(const char* path, const ParsedTypes& types,
std::string* error_msg) {
if (!path || !*path) {
if (error_msg) *error_msg = "path vacio";
return false;
}
std::ofstream f(path);
if (!f.good()) {
if (error_msg) *error_msg = std::string("cannot open ") + path;
return false;
}
f << "# types.yaml — autogenerado por graph_explorer Type Editor.\n";
f << "# Editable a mano. Round-trip con types_load_yaml/types_save_yaml.\n";
f << "# Todos los nodos son circulo, salvo \"Table\" que es cuadrado.\n";
f << "# Tipos de campo: string | int | float | bool | date | url | enum.\n";
f << "\n";
f << "entities:\n";
for (const auto& e : types.entities) {
f << " - name: " << e.name << "\n";
std::string col = color_to_hex(e.color);
if (!col.empty()) f << " color: \"" << col << "\"\n";
if (!e.icon_name.empty()) f << " icon: " << e.icon_name << "\n";
if (!e.principal_field.empty())
f << " principal_field: " << e.principal_field << "\n";
if (!e.fields.empty()) {
f << " fields:\n";
for (const auto& fs : e.fields) {
f << " - " << serialize_field(fs) << "\n";
}
}
f << "\n";
}
f << "relations:\n";
for (const auto& r : types.relations) {
f << " - name: " << r.name << "\n";
std::string col = color_to_hex(r.color);
if (!col.empty()) f << " color: \"" << col << "\"\n";
const char* st = style_name(r.style);
if (st && *st) f << " style: " << st << "\n";
f << "\n";
}
return true;
}
// ----------------------------------------------------------------------------
// Mapeo nombre Tabler -> codepoint
// ----------------------------------------------------------------------------
+40 -2
View File
@@ -9,13 +9,18 @@
namespace ge {
// Representacion en memoria de un `types.yaml` minimo:
// Representacion en memoria de un `types.yaml`:
//
// entities:
// - name: Person
// color: "#5B8DEF"
// shape: circle
// icon: ti-user
// principal_field: name # opcional, default = "name"
// fields:
// - { name: name, type: string, required: true }
// - { name: first_name, type: string }
// - { name: age, type: int }
// - { name: gender, type: enum, values: [male, female, other] }
// relations:
// - name: owns
// color: "#888888"
@@ -24,11 +29,36 @@ namespace ge {
// Campos no presentes quedan con sentinel (`0` para color, `SHAPE_USE_TYPE`
// para shape, `EDGE_USE_TYPE` para style, codepoint = 0 para icon).
// Tipos de campo soportados en el schema. v1 — ampliable. Tipos desconocidos
// se mapean a FK_STRING con warning del parser.
enum FieldKind : uint8_t {
FK_STRING = 0,
FK_INT,
FK_FLOAT,
FK_BOOL,
FK_DATE, // YYYY-MM-DD (validacion blanda en el inspector)
FK_URL,
FK_ENUM, // requiere `values: [...]`
};
const char* field_kind_name(FieldKind k);
FieldKind field_kind_from_name(const char* s); // case-insensitive; default FK_STRING
struct FieldSpec {
std::string name;
FieldKind kind = FK_STRING;
bool required = false;
std::vector<std::string> enum_values; // solo si kind == FK_ENUM
};
struct EntitySpec {
std::string name;
uint32_t color = 0; // ABGR; 0 = no override
uint8_t shape = SHAPE_USE_TYPE; // 0 = no override
uint16_t icon_cp = 0; // codepoint Tabler; 0 = sin icono
std::string icon_name; // nombre original ti-* (para round-trip)
std::string principal_field; // vacio = usar "name"
std::vector<FieldSpec> fields; // schema; vacio = sin schema
};
struct RelationSpec {
@@ -47,6 +77,14 @@ struct ParsedTypes {
// no es reconocido se ignora silenciosamente.
bool types_load_yaml(const char* path, ParsedTypes* out, std::string* error_msg);
// Writer de `types.yaml`. Escribe el formato compacto de `examples/types.yaml`
// (un dict por entity/relation, sub-keys indentadas, fields como inline-maps).
// Round-trip estable: load → save → load produce el mismo ParsedTypes.
//
// Devuelve true en exito. En fallo, `error_msg` describe el motivo.
bool types_save_yaml(const char* path, const ParsedTypes& types,
std::string* error_msg);
// Resuelve un nombre tipo `ti-user` -> codepoint Tabler. Devuelve 0 si no
// existe. La tabla de mapeo vive dentro de la implementacion y solo cubre
// los iconos comunes que el ejemplo OSINT usa; ampliable a demanda.