diff --git a/examples/types.yaml b/examples/types.yaml index 9c1d793..d41fdef 100644 --- a/examples/types.yaml +++ b/examples/types.yaml @@ -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 diff --git a/issues/0005-type-schema-fields.md b/issues/completed/0005-type-schema-fields.md similarity index 98% rename from issues/0005-type-schema-fields.md rename to issues/completed/0005-type-schema-fields.md index 5fe8e7b..b7e0ea0 100644 --- a/issues/0005-type-schema-fields.md +++ b/issues/completed/0005-type-schema-fields.md @@ -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 diff --git a/main.cpp b/main.cpp index 442ad87..c4ea31f 100644 --- a/main.cpp +++ b/main.cpp @@ -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 \n" " graph_explorer --types \n" " graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n" - " graph_explorer --project \n"); + " graph_explorer --project \n" + " graph_explorer --test-types-yaml (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; diff --git a/project_manager.cpp b/project_manager.cpp index 18a7851..51d4024 100644 --- a/project_manager.cpp +++ b/project_manager.cpp @@ -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 diff --git a/types_registry.cpp b/types_registry.cpp index 16ccff1..ac24545 100644 --- a/types_registry.cpp +++ b/types_registry.cpp @@ -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 split_top_level_commas(const std::string& s) { + std::vector 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 parse_inline_array(const std::string& s) { + std::vector 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 // ---------------------------------------------------------------------------- diff --git a/types_registry.h b/types_registry.h index 4e63102..ca0c4e2 100644 --- a/types_registry.h +++ b/types_registry.h @@ -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 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 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.