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 {