feat(types): schema en types.yaml — fields, principal_field, parser+writer
Issue 0005:
- types_registry.h: enum FieldKind {string,int,float,bool,date,url,enum},
struct FieldSpec {name,kind,required,enum_values}. EntitySpec gana
principal_field, fields[], icon_name (para round-trip exacto).
- Parser: tolerante con yaml antiguo (sin fields). Soporta sub-key
'fields:' como lista multilinea de inline-maps con bracket-aware
split (respeta [a,b,c] sin partir). Soporta 'principal_field:'.
Tipos desconocidos → FK_STRING con warning.
- Writer types_save_yaml: emite formato compacto (un dict por entity,
fields como inline-maps, color en #RRGGBB[AA], icon por nombre).
Round-trip estable: load→save→load produce ParsedTypes identico.
apply_types_yaml sigue ignorando fields (eso lo consumen 0007/0008).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+271
-35
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user