diff --git a/entity_ops.cpp b/entity_ops.cpp index dc95406..a095c25 100644 --- a/entity_ops.cpp +++ b/entity_ops.cpp @@ -329,6 +329,329 @@ bool entity_set_notes(const char* db_path, const char* id, const char* notes) { return ok; } +// ---------------------------------------------------------------------------- +// JSON helpers — parser + writer minimal para objetos planos y arrays de +// strings. NO es un parser JSON general (no soporta objetos anidados, ni +// arrays anidados, ni escapes Unicode \uXXXX). Suficiente para tags JSON +// y metadata flat del Inspector. +// ---------------------------------------------------------------------------- + +namespace { + +void skip_ws(const char*& p, const char* end) { + while (p < end) { + char c = *p; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { ++p; continue; } + break; + } +} + +// Parse un string JSON: comillas + contenido con escapes basicos. Devuelve +// el contenido decodificado en `out`. Avanza `p`. Retorna false si malformado. +bool parse_json_string(const char*& p, const char* end, std::string* out) { + if (p >= end || *p != '"') return false; + ++p; + out->clear(); + while (p < end) { + char c = *p++; + if (c == '"') return true; + if (c == '\\' && p < end) { + char e = *p++; + switch (e) { + case 'n': out->push_back('\n'); break; + case 't': out->push_back('\t'); break; + case 'r': out->push_back('\r'); break; + case '"': out->push_back('"'); break; + case '\\': out->push_back('\\'); break; + case '/': out->push_back('/'); break; + case 'b': out->push_back('\b'); break; + case 'f': out->push_back('\f'); break; + case 'u': + // Skip 4 hex chars; emit '?' (no soportamos Unicode escapes). + for (int i = 0; i < 4 && p < end; ++i) ++p; + out->push_back('?'); + break; + default: + out->push_back(e); + break; + } + } else { + out->push_back(c); + } + } + return false; +} + +// Parse un literal: numero, true, false, null. Avanza `p`. Devuelve raw +// (lo que aparece literalmente en el JSON, sin comillas). +bool parse_json_literal(const char*& p, const char* end, std::string* out) { + out->clear(); + while (p < end) { + char c = *p; + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || + c == '\n' || c == '\r') + break; + out->push_back(c); + ++p; + } + return !out->empty(); +} + +void escape_json_string(const std::string& s, std::string* out) { + out->push_back('"'); + for (char c : s) { + switch (c) { + case '"': *out += "\\\""; break; + case '\\': *out += "\\\\"; break; + case '\n': *out += "\\n"; break; + case '\r': *out += "\\r"; break; + case '\t': *out += "\\t"; break; + default: + if ((unsigned char)c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)(unsigned char)c); + *out += buf; + } else { + out->push_back(c); + } + } + } + out->push_back('"'); +} + +// Parsea `["a","b",...]` (array plano de strings). Devuelve true si OK. +// Tolerante: si no es un array, devuelve false con `out` vacio. +bool parse_string_array(const std::string& src, std::vector* out) { + out->clear(); + if (src.empty()) return true; + const char* p = src.c_str(); + const char* end = p + src.size(); + skip_ws(p, end); + if (p >= end || *p != '[') return false; + ++p; + skip_ws(p, end); + if (p < end && *p == ']') return true; // [] + while (p < end) { + skip_ws(p, end); + std::string s; + if (*p == '"') { + if (!parse_json_string(p, end, &s)) return false; + } else { + if (!parse_json_literal(p, end, &s)) return false; + } + out->push_back(s); + skip_ws(p, end); + if (p >= end) return false; + if (*p == ',') { ++p; continue; } + if (*p == ']') { ++p; return true; } + return false; + } + return false; +} + +// Parsea `{"k":"v","k2":42,"k3":true}` (objeto plano). Devuelve los pares +// en orden, con `is_string=true` si el valor venia entre comillas. +bool parse_flat_object(const std::string& src, std::vector* out) { + out->clear(); + if (src.empty()) return true; + const char* p = src.c_str(); + const char* end = p + src.size(); + skip_ws(p, end); + if (p >= end || *p != '{') return false; + ++p; + skip_ws(p, end); + if (p < end && *p == '}') return true; // {} + while (p < end) { + skip_ws(p, end); + std::string key; + if (*p != '"' || !parse_json_string(p, end, &key)) return false; + skip_ws(p, end); + if (p >= end || *p != ':') return false; + ++p; + skip_ws(p, end); + if (p >= end) return false; + MetadataField f; + f.key = std::move(key); + if (*p == '"') { + f.is_string = true; + if (!parse_json_string(p, end, &f.value_str)) return false; + } else if (*p == '{' || *p == '[') { + // Objeto/array anidado: consumir como literal hasta el cierre balanceado. + int depth = 0; + while (p < end) { + char c = *p++; + f.value_str.push_back(c); + if (c == '{' || c == '[') ++depth; + else if (c == '}' || c == ']') { + --depth; + if (depth == 0) break; + } + } + f.is_string = false; + } else { + f.is_string = false; + if (!parse_json_literal(p, end, &f.value_str)) return false; + } + out->push_back(std::move(f)); + skip_ws(p, end); + if (p >= end) return false; + if (*p == ',') { ++p; continue; } + if (*p == '}') { ++p; return true; } + return false; + } + return false; +} + +void build_string_array(const std::vector& xs, std::string* out) { + out->push_back('['); + for (size_t i = 0; i < xs.size(); ++i) { + if (i) out->push_back(','); + escape_json_string(xs[i], out); + } + out->push_back(']'); +} + +void build_flat_object(const std::vector& fs, std::string* out) { + out->push_back('{'); + bool first = true; + for (const auto& f : fs) { + if (f.key.empty()) continue; + // Saltar fields con valor vacio para no ensuciar el JSON con + // claves "" / null. La excepcion es bool/string vacio explicito — + // pero en v1 las claves vacias se omiten. + if (f.is_string && f.value_str.empty()) continue; + if (!f.is_string && f.value_str.empty()) continue; + if (!first) out->push_back(','); + first = false; + escape_json_string(f.key, out); + out->push_back(':'); + if (f.is_string) { + escape_json_string(f.value_str, out); + } else { + // Validar literal minimamente: si no es number/bool/null/array/object, + // re-emitir como string para no producir JSON invalido. + const std::string& v = f.value_str; + char first_c = v[0]; + bool ok_literal = + (first_c == '-' || (first_c >= '0' && first_c <= '9')) || + v == "true" || v == "false" || v == "null" || + first_c == '{' || first_c == '['; + if (ok_literal) { + *out += v; + } else { + escape_json_string(v, out); + } + } + } + out->push_back('}'); +} + +} // namespace + +bool entity_load_full(const char* db_path, const char* id, EntityRecord* out) { + if (!db_path || !id || !out) return false; + *out = EntityRecord{}; + out->id = id; + 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; + const char* sql = + "SELECT name, type_ref, description, status, tags, metadata " + "FROM entities WHERE id = ?"; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT); + bool ok = false; + if (sqlite3_step(st) == SQLITE_ROW) { + auto col = [&](int i) { + const unsigned char* p = sqlite3_column_text(st, i); + return std::string(p ? (const char*)p : ""); + }; + out->name = col(0); + out->type_ref = col(1); + out->description = col(2); + out->status = col(3); + std::string tags_json = col(4); + std::string meta_json = col(5); + parse_string_array(tags_json, &out->tags); + parse_flat_object(meta_json, &out->metadata); + ok = true; + } + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +bool entity_update(const char* db_path, const EntityRecord& rec) { + if (!db_path || rec.id.empty()) 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 tags_json; + std::string meta_json; + build_string_array(rec.tags, &tags_json); + build_flat_object(rec.metadata, &meta_json); + std::string ts = now_iso(); + std::string status = rec.status.empty() ? std::string("active") : rec.status; + + const char* sql = + "UPDATE entities SET name = ?, type_ref = ?, description = ?, " + "status = ?, tags = ?, metadata = ?, updated_at = ? WHERE id = ?"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, rec.name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, rec.type_ref.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, rec.description.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, status.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, tags_json.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, meta_json.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, ts.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, rec.id.c_str(), -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +bool entity_list_distinct_tags(const char* db_path, + std::vector* out) +{ + if (!db_path || !out) return false; + out->clear(); + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + // json_each esta disponible (SQLITE_ENABLE_JSON1 — ver CMakeLists del + // amalgamation). Solo strings como tag (kind = 'text'). + const char* sql = + "SELECT DISTINCT json_each.value FROM entities, json_each(entities.tags) " + "WHERE json_each.type = 'text' ORDER BY json_each.value"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -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 && *p) out->emplace_back((const char*)p); + } + sqlite3_finalize(st); + sqlite3_close(db); + return true; +} + // ---------------------------------------------------------------------------- // Index user_data -> sql id // ---------------------------------------------------------------------------- diff --git a/entity_ops.h b/entity_ops.h index 978e6f1..b084a80 100644 --- a/entity_ops.h +++ b/entity_ops.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include // Operaciones CRUD sobre operations.db (entities + relations) y deteccion // heuristica de tipo a partir de texto libre. Pensado para que la toolbar y @@ -58,6 +60,45 @@ bool entity_get_notes(const char* db_path, const char* id, std::string* out); // Sobrescribe `notes` con el contenido proporcionado. Toca `updated_at`. bool entity_set_notes(const char* db_path, const char* id, const char* notes); +// ---------------------------------------------------------------------------- +// Inspector editable (issue 0008) +// ---------------------------------------------------------------------------- + +// Un campo de la columna `metadata` JSON. `is_string=true` se emite como +// "..." en JSON; false se emite como literal (number/bool/null). El parser +// rellena esto al leer; el caller puede sobreescribirlo basado en el schema +// del tipo antes de guardar. +struct MetadataField { + std::string key; + std::string value_str; + bool is_string = true; +}; + +// Snapshot completo de los campos editables de una entidad. No incluye +// `notes` (panel separado) ni `created_at` (no editable). +struct EntityRecord { + std::string id; + std::string name; + std::string type_ref; + std::string description; + std::string status; // active|stale|corrupted|archived + std::vector tags; + std::vector metadata; // orden preservado del JSON original +}; + +// Carga el snapshot editable de la entidad. Devuelve false si no existe o +// SQLite falla. Tags y metadata se parsean desde JSON. +bool entity_load_full(const char* db_path, const char* id, EntityRecord* out); + +// Persiste el snapshot. Toca `updated_at`. Tags y metadata se serializan a +// JSON. Devuelve false si SQLite falla. +bool entity_update(const char* db_path, const EntityRecord& rec); + +// Lista las tags distintas en uso en toda la BD (para autocomplete del +// chip-input del Inspector). Sin orden particular. +bool entity_list_distinct_tags(const char* db_path, + std::vector* out); + // 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 { diff --git a/issues/0008-inspector-editable.md b/issues/completed/0008-inspector-editable.md similarity index 98% rename from issues/0008-inspector-editable.md rename to issues/completed/0008-inspector-editable.md index e55d7a5..2370222 100644 --- a/issues/0008-inspector-editable.md +++ b/issues/completed/0008-inspector-editable.md @@ -1,9 +1,10 @@ --- id: 0008 title: Inspector editable — campos del schema + tags + extras -status: pending +status: completed priority: high created: 2026-04-30 +completed: 2026-05-01 depends_on: [0005] --- diff --git a/main.cpp b/main.cpp index c4ea31f..9cd0c4d 100644 --- a/main.cpp +++ b/main.cpp @@ -153,6 +153,8 @@ static bool switch_to_project(const std::string& slug) { // Aplica paths nuevos y abre BDs apply_project_paths(slug); + ge::views_inspector_clear_draft(g_app); + g_app.parsed_types = ge::ParsedTypes{}; if (!ge::layout_store_open(g_layout_db_path.c_str())) { std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n", g_layout_db_path.c_str()); @@ -200,6 +202,8 @@ static bool load_input() { " %zu relations, %zu icons\n", pt.entities.size(), with_schema, total_fields, pt.relations.size(), codepoints.size()); + // Stash en AppState para que el Inspector resuelva schemas (issue 0008). + g_app.parsed_types = std::move(pt); } } @@ -243,6 +247,12 @@ static bool load_input() { ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); + // Inspector: refresca caches (tags distintas, lista de tipos) y limpia + // cualquier draft anterior. El draft se cargara cuando el usuario + // seleccione un nodo en el render loop. + ge::views_inspector_clear_draft(g_app); + ge::views_inspector_refresh_caches(g_app); + // --layout inicial (si llego del CLI) int idx = layout_name_to_index(g_layout_initial); if (idx >= 0) { @@ -638,6 +648,68 @@ static void render() { g_app.want_change_type = false; } + // ---- Inspector (issue 0008): sync draft con seleccion + save/discard ---- + { + const auto& sel = g_viewport.selection; + if (sel.size() == 1) { + int sidx = sel.front(); + if (sidx >= 0 && sidx < g_graph.node_count + && sidx != g_app.insp_node_idx + && !g_app.insp_dirty) { + const char* sql_id = ge::entity_index_lookup( + g_idx, g_graph.nodes[sidx].user_data); + ge::views_inspector_load_draft(g_app, sidx, sql_id); + } + } + } + + if (g_app.want_inspector_save && !g_app.insp_entity_id.empty()) { + ge::EntityRecord rec = ge::views_inspector_build_record(g_app); + if (ge::entity_update(g_app.input_db_path.c_str(), rec)) { + std::fprintf(stdout, "[graph_explorer] saved entity %s\n", + rec.id.c_str()); + // Reload del grafo para que cambios de name/type/etc. se reflejen + // en el viewport (label, color del tipo, etc.). + 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); + int restored = ge::layout_store_load(g_graph_hash, g_graph); + (void)restored; + g_atlas_bound = false; + g_gpu_dirty = true; + } + ge::views_inspector_refresh_caches(g_app); + // Re-cargar draft tras el reload (los node_idx pueden haber cambiado + // por reordenamiento de la BD). Buscamos el nuevo idx por sql_id. + int new_idx = -1; + for (int i = 0; i < g_graph.node_count; ++i) { + const char* sid = ge::entity_index_lookup( + g_idx, g_graph.nodes[i].user_data); + if (sid && rec.id == sid) { new_idx = i; break; } + } + if (new_idx >= 0) { + ge::views_inspector_load_draft(g_app, new_idx, rec.id.c_str()); + graph_viewport_clear_selection(g_graph, g_viewport); + graph_viewport_add_to_selection(g_graph, g_viewport, new_idx); + } else { + ge::views_inspector_clear_draft(g_app); + } + } else { + std::fprintf(stderr, "[graph_explorer] entity_update failed for %s\n", + rec.id.c_str()); + } + g_app.want_inspector_save = false; + } + + if (g_app.want_inspector_discard && !g_app.insp_entity_id.empty()) { + int idx = g_app.insp_node_idx; + std::string id = g_app.insp_entity_id; + ge::views_inspector_load_draft(g_app, idx, id.c_str()); + g_app.want_inspector_discard = false; + } + // Reset layout: limpia NF_PINNED en todos los nodos. El layout activo se // reaplica via apply_layout_tick (la toolbar ya lo incrementa). if (g_app.want_unpin_all) { diff --git a/views.cpp b/views.cpp index 4e85ee2..59a4948 100644 --- a/views.cpp +++ b/views.cpp @@ -17,7 +17,11 @@ #include "imgui.h" +#include +#include +#include #include +#include #include namespace ge { @@ -283,7 +287,191 @@ void views_legend(AppState& app) { } // ---------------------------------------------------------------------------- -// Inspector +// Inspector helpers (issue 0008) +// ---------------------------------------------------------------------------- + +namespace { + +const char* k_status_options[] = { "active", "stale", "corrupted", "archived" }; +constexpr int k_status_count = 4; + +int status_to_idx(const std::string& s) { + for (int i = 0; i < k_status_count; ++i) { + if (s == k_status_options[i]) return i; + } + return 0; +} + +const EntitySpec* find_entity_spec(const ParsedTypes& pt, const char* name) { + if (!name || !*name) return nullptr; + auto eq_ci = [&](const std::string& a, const char* b) { + if (a.size() != std::strlen(b)) return false; + for (size_t i = 0; i < a.size(); ++i) { + if (std::tolower((unsigned char)a[i]) != + std::tolower((unsigned char)b[i])) return false; + } + return true; + }; + for (const auto& e : pt.entities) { + if (eq_ci(e.name, name)) return &e; + } + return nullptr; +} + +// Asegura que el buffer de descripcion tiene al menos `need` bytes (con NUL). +void ensure_desc_buf(std::vector& buf, size_t need) { + if (need < 4096) need = 4096; + if (buf.size() < need) buf.assign(need, 0); +} + +void copy_to_buf(char* buf, size_t n, const std::string& s) { + if (n == 0) return; + size_t k = std::min(n - 1, s.size()); + std::memcpy(buf, s.data(), k); + buf[k] = 0; +} + +} // namespace + +void views_inspector_clear_draft(AppState& app) { + app.insp_node_idx = -1; + app.insp_entity_id.clear(); + app.insp_name_buf[0] = 0; + app.insp_type_buf[0] = 0; + app.insp_desc_buf.clear(); + app.insp_status_idx = 0; + app.insp_field_keys.clear(); + app.insp_field_values.clear(); + app.insp_is_extra.clear(); + app.insp_tags.clear(); + app.insp_tag_input[0] = 0; + app.insp_extra_key[0] = 0; + app.insp_dirty = false; + app.insp_show_unsaved = false; + app.insp_pending_target = -1; +} + +void views_inspector_refresh_caches(AppState& app) { + app.insp_tag_suggestions.clear(); + if (!app.input_db_path.empty()) { + entity_list_distinct_tags(app.input_db_path.c_str(), + &app.insp_tag_suggestions); + } + app.insp_type_options.clear(); + for (const auto& e : app.parsed_types.entities) { + if (!e.name.empty()) app.insp_type_options.push_back(e.name); + } + // Si el grafo trae tipos no presentes en el yaml, anadirlos para que el + // combo no pierda opciones. + if (app.graph) { + for (int i = 0; i < app.graph->type_count; ++i) { + const char* nm = app.graph->types[i].name; + if (!nm || !*nm) continue; + bool dup = false; + for (const auto& s : app.insp_type_options) { + if (s == nm) { dup = true; break; } + } + if (!dup) app.insp_type_options.emplace_back(nm); + } + } +} + +void views_inspector_load_draft(AppState& app, int node_idx, + const char* entity_id) { + views_inspector_clear_draft(app); + if (!entity_id || !*entity_id) return; + EntityRecord rec; + if (!entity_load_full(app.input_db_path.c_str(), entity_id, &rec)) return; + + app.insp_node_idx = node_idx; + app.insp_entity_id = entity_id; + copy_to_buf(app.insp_name_buf, sizeof(app.insp_name_buf), rec.name); + copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf), rec.type_ref); + ensure_desc_buf(app.insp_desc_buf, rec.description.size() + 4096); + std::memcpy(app.insp_desc_buf.data(), rec.description.data(), + rec.description.size()); + app.insp_status_idx = status_to_idx(rec.status); + + // Construye lista de fields: primero los del schema (en orden), luego + // las extras (claves en metadata que no estan en el schema). + const EntitySpec* spec = find_entity_spec(app.parsed_types, rec.type_ref.c_str()); + auto find_meta = [&](const std::string& key) -> const MetadataField* { + for (const auto& m : rec.metadata) if (m.key == key) return &m; + return nullptr; + }; + if (spec) { + for (const auto& f : spec->fields) { + std::string val; + if (auto m = find_meta(f.name)) val = m->value_str; + app.insp_field_keys.push_back(f.name); + app.insp_field_values.push_back(std::move(val)); + app.insp_is_extra.push_back(0); + } + } + // Extras: claves en metadata que no estan en el schema. + for (const auto& m : rec.metadata) { + bool in_schema = false; + if (spec) { + for (const auto& f : spec->fields) { + if (f.name == m.key) { in_schema = true; break; } + } + } + if (!in_schema) { + app.insp_field_keys.push_back(m.key); + app.insp_field_values.push_back(m.value_str); + app.insp_is_extra.push_back(1); + } + } + app.insp_tags = std::move(rec.tags); + app.insp_dirty = false; +} + +EntityRecord views_inspector_build_record(const AppState& app) { + EntityRecord r; + r.id = app.insp_entity_id; + r.name = app.insp_name_buf; + r.type_ref = app.insp_type_buf; + r.description = app.insp_desc_buf.empty() + ? std::string() + : std::string(app.insp_desc_buf.data()); + int sidx = app.insp_status_idx; + if (sidx < 0 || sidx >= k_status_count) sidx = 0; + r.status = k_status_options[sidx]; + r.tags = app.insp_tags; + + const EntitySpec* spec = find_entity_spec(app.parsed_types, + app.insp_type_buf); + auto kind_for_key = [&](const std::string& k) -> FieldKind { + if (!spec) return FK_STRING; + for (const auto& f : spec->fields) if (f.name == k) return f.kind; + return FK_STRING; + }; + for (size_t i = 0; i < app.insp_field_keys.size(); ++i) { + const std::string& key = app.insp_field_keys[i]; + const std::string& val = app.insp_field_values[i]; + if (key.empty() || val.empty()) continue; + bool is_extra = app.insp_is_extra[i] != 0; + FieldKind kind = is_extra ? FK_STRING : kind_for_key(key); + MetadataField mf; + mf.key = key; + mf.value_str = val; + mf.is_string = (kind == FK_STRING || kind == FK_DATE + || kind == FK_URL || kind == FK_ENUM); + // Para int/float/bool, value_str debe ser literal valido. + if (kind == FK_BOOL) { + std::string lv = val; + std::transform(lv.begin(), lv.end(), lv.begin(), + [](unsigned char c){ return std::tolower(c); }); + mf.value_str = (lv == "true" || lv == "1" || lv == "yes") + ? "true" : "false"; + } + r.metadata.push_back(std::move(mf)); + } + return r; +} + +// ---------------------------------------------------------------------------- +// Inspector — render del panel editable // ---------------------------------------------------------------------------- void views_inspector(AppState& app) { @@ -302,14 +490,35 @@ void views_inspector(AppState& app) { const auto& sel = app.viewport->selection; if (sel.empty()) { + if (app.insp_node_idx != -1 && !app.insp_dirty) { + views_inspector_clear_draft(app); + } ImGui::TextUnformatted("No selection."); ImGui::TextWrapped("Click a node, or shift+drag to lasso a region."); + if (app.insp_dirty) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f)); + ImGui::TextWrapped("Cambios sin guardar en %s", + app.insp_entity_id.c_str()); + ImGui::PopStyleColor(); + if (fn_ui::button("Save", fn_ui::ButtonVariant::Primary)) { + app.want_inspector_save = true; + } + ImGui::SameLine(); + if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) { + app.want_inspector_discard = true; + } + } ImGui::End(); return; } if (sel.size() > 1) { + if (app.insp_node_idx != -1 && !app.insp_dirty) { + views_inspector_clear_draft(app); + } ImGui::Text("%zu nodes selected", sel.size()); + ImGui::TextDisabled("(edicion multi-seleccion no soportada)"); ImGui::Separator(); for (size_t i = 0; i < sel.size() && i < 32; ++i) { int idx = sel[i]; @@ -329,28 +538,335 @@ void views_inspector(AppState& app) { ImGui::End(); return; } + + // Sincroniza draft con seleccion actual. Si hay cambios pendientes y la + // seleccion cambio, mostramos un banner pidiendo Save/Discard antes de + // cargar el nodo nuevo. Mientras tanto el draft sigue siendo el del + // nodo anterior. + if (app.insp_node_idx != idx) { + if (app.insp_dirty && app.insp_node_idx >= 0 + && app.insp_node_idx < g.node_count) { + // No cargar — esperar decision del usuario. + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f)); + ImGui::TextWrapped("Cambios sin guardar (nodo cambio en el viewport)." + " Save / Discard primero."); + ImGui::PopStyleColor(); + ImGui::Separator(); + } else { + // Resolver el sql id desde el viewport callback no esta aqui, + // asi que main.cpp debe haber rellenado insp_pending_target + + // input_db_path; pero la ruta normal es: main.cpp detecta cambio + // de seleccion y llama views_inspector_load_draft. Aqui solo + // limpiamos si el user_data no resuelve. + // Simplemente intentamos cargar via insp_pending_target. + } + } + + // Si el draft no esta cargado para este idx, mostramos un placeholder. + // main.cpp es responsable de llamar load_draft cuando seleccion cambia + // y no hay dirty. + if (app.insp_node_idx != idx) { + ImGui::TextDisabled("Cargando nodo %d...", idx); + ImGui::End(); + return; + } + const GraphNode& n = g.nodes[idx]; - const char* lbl = graph::graph_label(&g, n.label_idx); - const char* tname = (n.type_id < (uint16_t)g.type_count && g.types[n.type_id].name) - ? g.types[n.type_id].name : "(no-type)"; - ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); - ImGui::TextUnformatted("label:"); + ImGui::Text("id=%s user_data=%llx pos=(%.1f, %.1f)", + app.insp_entity_id.c_str(), + (unsigned long long)n.user_data, n.x, n.y); ImGui::PopStyleColor(); + ImGui::Separator(); + + bool any_change = false; + + // ---- Identidad ---- + ImGui::TextUnformatted("Identity"); + ImGui::Separator(); + if (ImGui::InputText("name", app.insp_name_buf, sizeof(app.insp_name_buf))) + any_change = true; + + // type combo + { + int cur = -1; + for (size_t i = 0; i < app.insp_type_options.size(); ++i) { + if (app.insp_type_options[i] == app.insp_type_buf) { cur = (int)i; break; } + } + // Si el tipo no esta en el cache (raro), mostrar como tal y permitir + // introducirlo via input. Combo simple aqui. + if (ImGui::BeginCombo("type", app.insp_type_buf)) { + for (size_t i = 0; i < app.insp_type_options.size(); ++i) { + bool is_sel = (int)i == cur; + if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) { + copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf), + app.insp_type_options[i]); + any_change = true; + } + if (is_sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + // status combo + if (ImGui::Combo("status", &app.insp_status_idx, + k_status_options, k_status_count)) + any_change = true; + // description multiline + if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096); + if (ImGui::InputTextMultiline("description", + app.insp_desc_buf.data(), + app.insp_desc_buf.size(), + ImVec2(-FLT_MIN, 60.0f))) + any_change = true; + + // ---- Schema fields + Extras ---- + if (!app.insp_field_keys.empty()) { + ImGui::Spacing(); + ImGui::TextUnformatted("Fields"); + ImGui::Separator(); + const EntitySpec* spec = find_entity_spec(app.parsed_types, + app.insp_type_buf); + for (size_t i = 0; i < app.insp_field_keys.size(); ++i) { + const std::string& key = app.insp_field_keys[i]; + std::string& val = app.insp_field_values[i]; + bool is_extra = app.insp_is_extra[i] != 0; + ImGui::PushID((int)i); + + // Encuentra la FieldSpec si es del schema. + const FieldSpec* fs = nullptr; + if (!is_extra && spec) { + for (const auto& f : spec->fields) { + if (f.name == key) { fs = &f; break; } + } + } + + FieldKind kind = fs ? fs->kind : FK_STRING; + std::string label = key; + if (fs && fs->required) label += " *"; + if (is_extra) label = "[extra] " + key; + + char buf[1024]; + size_t k = std::min(sizeof(buf) - 1, val.size()); + std::memcpy(buf, val.data(), k); + buf[k] = 0; + + bool changed = false; + switch (kind) { + case FK_BOOL: { + bool b = (val == "true" || val == "1"); + if (ImGui::Checkbox(label.c_str(), &b)) { + val = b ? "true" : "false"; + changed = true; + } + break; + } + case FK_INT: { + int n = std::atoi(val.c_str()); + if (ImGui::InputInt(label.c_str(), &n)) { + char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n); + val = nb; + changed = true; + } + break; + } + case FK_FLOAT: { + double d = std::atof(val.c_str()); + if (ImGui::InputDouble(label.c_str(), &d, 0.0, 0.0, "%.6g")) { + char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d); + val = nb; + changed = true; + } + break; + } + case FK_ENUM: { + if (fs && !fs->enum_values.empty()) { + int cur = -1; + for (size_t e = 0; e < fs->enum_values.size(); ++e) { + if (fs->enum_values[e] == val) { cur = (int)e; break; } + } + if (ImGui::BeginCombo(label.c_str(), val.c_str())) { + for (size_t e = 0; e < fs->enum_values.size(); ++e) { + bool is_sel = (int)e == cur; + if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) { + val = fs->enum_values[e]; + changed = true; + } + if (is_sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } else { + // Sin valores: tratar como string + if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) { + val = buf; + changed = true; + } + } + break; + } + case FK_URL: + if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) { + val = buf; + changed = true; + } + if (!val.empty() && + (val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) { + ImGui::SameLine(); + if (ImGui::SmallButton("Open##url")) { +#if defined(_WIN32) + std::string cmd = "start \"\" \"" + val + "\""; +#else + std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &"; +#endif + int rc = std::system(cmd.c_str()); (void)rc; + } + } + break; + case FK_DATE: + case FK_STRING: + default: + if (ImGui::InputTextWithHint(label.c_str(), + kind == FK_DATE ? "YYYY-MM-DD" : "", + buf, sizeof(buf))) { + val = buf; + changed = true; + } + break; + } + + if (is_extra) { + ImGui::SameLine(); + if (ImGui::SmallButton(TI_TRASH "##rm")) { + app.insp_field_keys.erase(app.insp_field_keys.begin() + i); + app.insp_field_values.erase(app.insp_field_values.begin() + i); + app.insp_is_extra.erase(app.insp_is_extra.begin() + i); + ImGui::PopID(); + any_change = true; + --i; + continue; + } + } + if (changed) any_change = true; + ImGui::PopID(); + } + } + + // ---- Add extra ---- + ImGui::Spacing(); + ImGui::TextUnformatted("Extra field"); + ImGui::Separator(); + ImGui::SetNextItemWidth(160); + ImGui::InputTextWithHint("##xkey", "key", app.insp_extra_key, + sizeof(app.insp_extra_key)); ImGui::SameLine(); - ImGui::TextUnformatted(lbl && *lbl ? lbl : "(none)"); + if (fn_ui::button(TI_PLUS " Add", fn_ui::ButtonVariant::Subtle)) { + if (app.insp_extra_key[0]) { + // Evitar colision con keys ya presentes + std::string k = app.insp_extra_key; + bool dup = false; + for (const auto& x : app.insp_field_keys) { + if (x == k) { dup = true; break; } + } + if (!dup) { + app.insp_field_keys.push_back(k); + app.insp_field_values.emplace_back(""); + app.insp_is_extra.push_back(1); + any_change = true; + } + app.insp_extra_key[0] = 0; + } + } - ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); - ImGui::TextUnformatted("type:"); - ImGui::PopStyleColor(); + // ---- Tags ---- + ImGui::Spacing(); + ImGui::TextUnformatted("Tags"); + ImGui::Separator(); + for (size_t i = 0; i < app.insp_tags.size(); ++i) { + ImGui::PushID((int)i); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.30f, 0.50f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.40f, 0.65f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.50f, 0.75f, 1.0f)); + std::string lbl = app.insp_tags[i] + " x"; + if (ImGui::SmallButton(lbl.c_str())) { + app.insp_tags.erase(app.insp_tags.begin() + i); + ImGui::PopStyleColor(3); + ImGui::PopID(); + any_change = true; + --i; + continue; + } + ImGui::PopStyleColor(3); + ImGui::PopID(); + if ((i + 1) % 4 != 0) ImGui::SameLine(); + } + if (!app.insp_tags.empty()) ImGui::NewLine(); + + ImGui::SetNextItemWidth(160); + ImGuiInputTextFlags tflags = ImGuiInputTextFlags_EnterReturnsTrue; + bool commit_tag = ImGui::InputTextWithHint("##taginput", "add tag, Enter", + app.insp_tag_input, + sizeof(app.insp_tag_input), + tflags); ImGui::SameLine(); - ImGui::TextUnformatted(tname); + if (fn_ui::button("Add tag", fn_ui::ButtonVariant::Subtle)) commit_tag = true; + if (commit_tag && app.insp_tag_input[0]) { + std::string t = app.insp_tag_input; + bool dup = false; + for (const auto& x : app.insp_tags) if (x == t) { dup = true; break; } + if (!dup) { + app.insp_tags.push_back(t); + any_change = true; + } + app.insp_tag_input[0] = 0; + } + if (!app.insp_tag_suggestions.empty()) { + ImGui::TextDisabled("(in db: %zu tags distintas)", app.insp_tag_suggestions.size()); + // Lista compacta clickable de las primeras 12 sugerencias. + int shown = 0; + for (const auto& s : app.insp_tag_suggestions) { + if (shown >= 12) { ImGui::TextDisabled("..."); break; } + // Ocultar las ya presentes + bool already = false; + for (const auto& x : app.insp_tags) if (x == s) { already = true; break; } + if (already) continue; + ImGui::SmallButton(s.c_str()); + if (ImGui::IsItemClicked()) { + app.insp_tags.push_back(s); + any_change = true; + } + ImGui::SameLine(); + ++shown; + } + if (shown > 0) ImGui::NewLine(); + } - ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); - ImGui::Text("idx=%d user_data=%llx pos=(%.1f, %.1f)", - idx, (unsigned long long)n.user_data, n.x, n.y); - ImGui::PopStyleColor(); + // ---- Footer: Save / Discard / Notes / Neighbors ---- + ImGui::Spacing(); + ImGui::Separator(); + if (any_change) app.insp_dirty = true; + if (app.insp_dirty) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f)); + ImGui::TextUnformatted("(modified)"); + ImGui::PopStyleColor(); + } + if (fn_ui::button(TI_DEVICE_FLOPPY " Save", + app.insp_dirty ? fn_ui::ButtonVariant::Primary + : fn_ui::ButtonVariant::Subtle)) { + app.want_inspector_save = true; + } + ImGui::SameLine(); + if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) { + app.want_inspector_discard = true; + } + ImGui::SameLine(); + if (fn_ui::button(TI_FILE_TEXT " Open notes", fn_ui::ButtonVariant::Subtle)) { + app.want_open_note = true; + app.open_note_target = idx; + } + + // ---- Neighbors (read-only) ---- ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("Neighbors:"); diff --git a/views.h b/views.h index 54c5582..89fb16c 100644 --- a/views.h +++ b/views.h @@ -3,6 +3,9 @@ #include #include +#include "types_registry.h" +#include "entity_ops.h" + struct GraphData; struct GraphViewportState; @@ -96,6 +99,43 @@ struct AppState { bool want_save_note = false; bool want_open_note = false; // doble click → cargar y abrir int open_note_target = -1; // node_idx a abrir + + // ---- Inspector editable (issue 0008) ---------------------------------- + // Schema vivo del proyecto activo (load/save desde types.yaml). + ParsedTypes parsed_types; + + // Draft del inspector — todo lo que el usuario esta editando para el + // nodo seleccionado. Se carga desde BD al cambiar la seleccion (si no + // hay cambios pendientes) y se persiste con entity_update al guardar. + int insp_node_idx = -1; + std::string insp_entity_id; + char insp_name_buf[256] = {}; + char insp_type_buf[64] = {}; + std::vector insp_desc_buf; // multiline + int insp_status_idx = 0; // 0=active 1=stale 2=corrupted 3=archived + + // Listas paralelas: keys + valores actuales de los campos de metadata. + // Las claves del schema del tipo van primero (en su orden), las "extras" + // van detras. `is_extra[i]` distingue para render diferenciado y para + // permitir borrar solo extras desde la UI. + std::vector insp_field_keys; + std::vector insp_field_values; + std::vector insp_is_extra; + + std::vector insp_tags; + char insp_tag_input[64] = {}; + char insp_extra_key[64] = {}; + + bool insp_dirty = false; + bool insp_show_unsaved = false; + int insp_pending_target = -1; + + bool want_inspector_save = false; + bool want_inspector_discard = false; + + // Caches refrescadas tras cargar grafo o tras Save. + std::vector insp_tag_suggestions; + std::vector insp_type_options; }; // Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout). @@ -133,4 +173,24 @@ void views_apply_visibility(AppState& app); // los tipos del grafo activo. Llamar tras cargar/recargar el grafo. void views_reset_visibility(AppState& app); +// ---- Inspector editable helpers (issue 0008) ------------------------------ + +// Refresca insp_tag_suggestions e insp_type_options leyendo BD y schema. +// Llamar tras cargar el grafo o tras un Save. +void views_inspector_refresh_caches(AppState& app); + +// Carga el draft del Inspector desde la BD para el nodo `node_idx`. Si el +// nodo no es resoluble o no existe, deja el draft vacio. No respeta dirty: +// el caller debe haberlo manejado ya. +void views_inspector_load_draft(AppState& app, int node_idx, + const char* entity_id); + +// Construye un EntityRecord desde el draft actual respetando el schema +// del type_ref para decidir is_string de cada metadata field. +EntityRecord views_inspector_build_record(const AppState& app); + +// Resetea el draft (todos los buffers + dirty=false). Util tras Save o +// al cambiar de proyecto. +void views_inspector_clear_draft(AppState& app); + } // namespace ge