diff --git a/entity_ops.cpp b/entity_ops.cpp index a095c25..a60bd3d 100644 --- a/entity_ops.cpp +++ b/entity_ops.cpp @@ -652,6 +652,126 @@ bool entity_list_distinct_tags(const char* db_path, return true; } +// ---------------------------------------------------------------------------- +// FTS5 search (issue 0009) +// ---------------------------------------------------------------------------- + +namespace { + +// Sanitiza la query del usuario para FTS5: drop de chars que rompen el +// parser, tokeniza por whitespace y envuelve cada token en comillas dobles +// con prefijo `*`. Asi `"john doe"` -> `"john"* "doe"*`. La presencia de +// varios tokens equivale a AND implicito en FTS5. +std::string fts5_sanitize(const char* q) { + std::string out; + if (!q) return out; + const char* p = q; + while (*p) { + while (*p && std::isspace((unsigned char)*p)) ++p; + if (!*p) break; + std::string tok; + while (*p && !std::isspace((unsigned char)*p)) { + char c = *p++; + // Drop chars con significado en FTS5. + if (c == '"' || c == '*' || c == '(' || c == ')' || c == ':' || + c == '^' || c == '\'' || c == '!' || c == '+') continue; + tok += c; + } + if (tok.empty()) continue; + if (!out.empty()) out += ' '; + out += '"'; + out += tok; + out += "\"*"; + } + return out; +} + +} // namespace + +bool entity_search_fts(const char* db_path, const char* query, int limit, + std::vector* out) +{ + if (!db_path || !out) return false; + out->clear(); + if (limit < 1) limit = 1; + if (limit > 200) limit = 200; + + std::string match = fts5_sanitize(query); + if (match.empty()) return true; + + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + + // entities_fts.id y entities.id colisionan en JOIN — calificamos siempre. + const char* sql = + "SELECT e.id, e.name, e.type_ref, bm25(entities_fts) AS r " + "FROM entities_fts JOIN entities AS e ON e.rowid = entities_fts.rowid " + "WHERE entities_fts MATCH ?1 ORDER BY r ASC LIMIT ?2"; + 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, match.c_str(), (int)match.size(), SQLITE_TRANSIENT); + sqlite3_bind_int (st, 2, limit); + + while (sqlite3_step(st) == SQLITE_ROW) { + EntityHit h; + const unsigned char* id = sqlite3_column_text(st, 0); + const unsigned char* name = sqlite3_column_text(st, 1); + const unsigned char* tref = sqlite3_column_text(st, 2); + h.id = id ? (const char*)id : ""; + h.name = name ? (const char*)name : ""; + h.type_ref = tref ? (const char*)tref : ""; + h.rank = sqlite3_column_double(st, 3); + out->push_back(std::move(h)); + } + sqlite3_finalize(st); + sqlite3_close(db); + return true; +} + +bool entity_list_by_tags(const char* db_path, + const std::vector& tags, + std::vector* out_ids) +{ + if (!db_path || !out_ids) return false; + out_ids->clear(); + if (tags.empty()) return true; + + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + + // Una clausula EXISTS por tag, todas en AND. + std::string sql = "SELECT id FROM entities WHERE "; + for (size_t i = 0; i < tags.size(); ++i) { + if (i) sql += " AND "; + sql += "EXISTS(SELECT 1 FROM json_each(entities.tags) WHERE json_each.value = ?)"; + } + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + for (size_t i = 0; i < tags.size(); ++i) { + sqlite3_bind_text(st, (int)(i + 1), tags[i].c_str(), + (int)tags[i].size(), SQLITE_TRANSIENT); + } + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (p && *p) out_ids->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 b084a80..da5d384 100644 --- a/entity_ops.h +++ b/entity_ops.h @@ -99,6 +99,29 @@ bool entity_update(const char* db_path, const EntityRecord& rec); bool entity_list_distinct_tags(const char* db_path, std::vector* out); +// Resultado de busqueda FTS5 sobre entities_fts. +struct EntityHit { + std::string id; + std::string name; + std::string type_ref; + double rank = 0.0; // bm25 (mas bajo = mejor) +}; + +// Busca en entities_fts (columnas: id, name, description, tags, domain). +// Tokeniza `query` por whitespace, descarta caracteres especiales FTS5 y +// aplica busqueda por prefijo a cada token (token*). Limit clampeado en [1,200]. +// Devuelve hits ordenados por rank ASC. Si query queda vacio tras sanear, +// devuelve true con out->empty(). Si la BD no tiene la tabla entities_fts +// (operations.db antiguos), devuelve false. +bool entity_search_fts(const char* db_path, const char* query, int limit, + std::vector* out); + +// Devuelve los ids de entidades cuyo array `tags` contiene TODOS los tags +// pasados (AND). Si `tags` vacio, out queda vacio y retorna true. +bool entity_list_by_tags(const char* db_path, + const std::vector& tags, + std::vector* out_ids); + // 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/0009-tag-filter-fts-search.md b/issues/completed/0009-tag-filter-fts-search.md similarity index 97% rename from issues/0009-tag-filter-fts-search.md rename to issues/completed/0009-tag-filter-fts-search.md index 179f534..436f8a1 100644 --- a/issues/0009-tag-filter-fts-search.md +++ b/issues/completed/0009-tag-filter-fts-search.md @@ -1,9 +1,10 @@ --- id: 0009 title: Filtro por tag + busqueda FTS5 en toolbar -status: pending +status: completed priority: medium created: 2026-04-30 +completed: 2026-05-01 depends_on: [0008] --- diff --git a/main.cpp b/main.cpp index 9cd0c4d..a1ede26 100644 --- a/main.cpp +++ b/main.cpp @@ -552,6 +552,19 @@ static void render() { std::fprintf(stdout, "[graph_explorer] saved %d node positions\n", n); g_app.want_save_layout = false; } + + // Filtro FTS5/tags (issue 0009) — reaplica si el toolbar marco dirty. + if (g_app.filter_dirty) { + ge::views_filter_apply(g_app); + } + // Centrado del nodo seleccionado desde el dropdown. + if (g_app.filter_focus_target >= 0 + && g_app.filter_focus_target < g_graph.node_count) { + const GraphNode& n = g_graph.nodes[g_app.filter_focus_target]; + g_viewport.cam_x = -n.x; + g_viewport.cam_y = -n.y; + g_app.filter_focus_target = -1; + } if (g_app.want_open_file) { g_input_path = g_app.open_buf; g_app.want_open_file = false; diff --git a/views.cpp b/views.cpp index 59a4948..ec01558 100644 --- a/views.cpp +++ b/views.cpp @@ -23,6 +23,7 @@ #include #include #include +#include namespace ge { @@ -65,20 +66,143 @@ void views_reset_visibility(AppState& app) { app.rel_type_visible_n = nr; } -void views_apply_visibility(AppState& app) { - if (!app.graph) return; - GraphData& g = *app.graph; - for (int i = 0; i < g.node_count; ++i) { - uint16_t t = g.nodes[i].type_id; - bool vis = (t < (uint16_t)app.type_visible_n) ? app.type_visible[t] : true; - if (vis) g.nodes[i].flags |= NF_VISIBLE; - else g.nodes[i].flags &= ~NF_VISIBLE; +// FNV1a-64 — debe coincidir con graph_sources.cpp (escribe el hash de id en +// node.user_data). Usado para mapear ids del FTS a nodos. +static uint64_t fnv1a64_id(const char* s) { + uint64_t h = 1469598103934665603ULL; + for (; s && *s; ++s) { + h ^= (uint8_t)*s; + h *= 1099511628211ULL; } + return h; +} + +void views_apply_visibility(AppState& app) { + // Toda la mascara (tipos + filter) se computa en views_filter_apply. + // Mantenemos esta firma por compatibilidad; basta con marcar dirty y + // dejar que el reapply integre todo de una pasada. + app.filter_dirty = true; + views_filter_apply(app); +} + +bool views_filter_active(const AppState& app) { + return app.filter_query_buf[0] != 0 || !app.filter_tags.empty(); +} + +void views_filter_add_tag(AppState& app, const char* tag) { + if (!tag || !*tag) return; + for (const auto& t : app.filter_tags) if (t == tag) return; + app.filter_tags.emplace_back(tag); + app.filter_dirty = true; +} + +void views_filter_clear(AppState& app) { + app.filter_query_buf[0] = 0; + app.filter_tags.clear(); + app.filter_hits.clear(); + app.filter_dropdown_open = false; + app.filter_dirty = true; +} + +void views_filter_apply(AppState& app) { + if (!app.graph) { app.filter_dirty = false; return; } + GraphData& g = *app.graph; + const bool has_filter = views_filter_active(app); + + // 1) Construir el set de ids matching (FTS query AND tags). Si el filtro + // esta inactivo, no hay set: todos los nodos son "match". + std::unordered_set match_hashes; + bool have_match_set = false; + + if (has_filter && !app.input_db_path.empty()) { + std::vector ids_query; + std::vector ids_tags; + bool have_q = (app.filter_query_buf[0] != 0); + bool have_t = !app.filter_tags.empty(); + + if (have_q) { + std::vector hits; + // Limit alto para que el filtro no recorte por encima del dropdown. + entity_search_fts(app.input_db_path.c_str(), + app.filter_query_buf, 200, &hits); + ids_query.reserve(hits.size()); + for (auto& h : hits) ids_query.emplace_back(std::move(h.id)); + } + if (have_t) { + entity_list_by_tags(app.input_db_path.c_str(), + app.filter_tags, &ids_tags); + } + + std::vector* primary = nullptr; + const std::vector* secondary = nullptr; + if (have_q && have_t) { + primary = &ids_query; + secondary = &ids_tags; + } else if (have_q) { + primary = &ids_query; + } else { + primary = &ids_tags; + } + + std::unordered_set sec_set; + if (secondary) sec_set.insert(secondary->begin(), secondary->end()); + + match_hashes.reserve(primary->size()); + for (const auto& id : *primary) { + if (secondary && !sec_set.count(id)) continue; + match_hashes.insert(fnv1a64_id(id.c_str())); + } + have_match_set = true; + } + + // 2) Refrescar dropdown (max 20). Solo cuando hay query — los chips de + // tags no contribuyen al dropdown (su feedback visual son los chips). + if (app.filter_query_buf[0]) { + app.filter_hits.clear(); + if (!app.input_db_path.empty()) { + entity_search_fts(app.input_db_path.c_str(), + app.filter_query_buf, 20, &app.filter_hits); + } + } else { + app.filter_hits.clear(); + } + + // 3) Aplicar mascara a nodos: type-filter + filter (query/tags) + modo. + const bool hide_mode = (app.filter_mode == AppState::FM_HIDE); + for (int i = 0; i < g.node_count; ++i) { + GraphNode& n = g.nodes[i]; + uint16_t t = n.type_id; + bool type_vis = (t < (uint16_t)app.type_visible_n) ? app.type_visible[t] : true; + + bool match = true; + if (have_match_set) match = match_hashes.count(n.user_data) > 0; + + if (!type_vis) { + n.flags &= ~NF_VISIBLE; + n.color_override = 0u; + continue; + } + + if (!have_match_set || match) { + n.flags |= NF_VISIBLE; + n.color_override = 0u; // restaurar color del tipo + } else if (hide_mode) { + n.flags &= ~NF_VISIBLE; + n.color_override = 0u; + } else { + // Highlight: dim al ~25% de alpha conservando RGB del tipo. + uint32_t base = resolve_node_color(n, g.types, g.type_count); + n.color_override = (base & 0x00FFFFFFu) | (0x40u << 24); + n.flags |= NF_VISIBLE; + } + } + + // 4) Edges: visibles si ambos endpoints son visibles y el rel_type lo + // permite. Mantiene el comportamiento previo. for (int i = 0; i < g.edge_count; ++i) { const GraphEdge& e = g.edges[i]; bool rel_vis = (e.type_id < (uint16_t)app.rel_type_visible_n) ? app.rel_type_visible[e.type_id] : true; - // Si los endpoints estan ocultos, la arista tambien. bool src_vis = (e.source < (uint32_t)g.node_count) && (g.nodes[e.source].flags & NF_VISIBLE); bool tgt_vis = (e.target < (uint32_t)g.node_count) && @@ -87,6 +211,8 @@ void views_apply_visibility(AppState& app) { if (vis) g.edges[i].flags |= EF_VISIBLE; else g.edges[i].flags &= ~EF_VISIBLE; } + + app.filter_dirty = false; } // ---------------------------------------------------------------------------- @@ -187,6 +313,112 @@ void views_toolbar(AppState& app) { ImGui::TextDisabled("[%s]", detected_type_name(dt)); toolbar_separator(); + // ---- Filtro / busqueda FTS5 (issue 0009) ----------------------- + { + ImGui::SetNextItemWidth(220); + ImGuiInputTextFlags ff = ImGuiInputTextFlags_EnterReturnsTrue; + char prev[128]; + std::snprintf(prev, sizeof(prev), "%s", app.filter_query_buf); + bool committed = ImGui::InputTextWithHint("##filterq", + TI_SEARCH " Search name/desc/tags...", + app.filter_query_buf, + sizeof(app.filter_query_buf), ff); + bool typing_changed = std::strcmp(prev, app.filter_query_buf) != 0; + if (typing_changed || committed) { + app.filter_dirty = true; + app.filter_dropdown_open = (app.filter_query_buf[0] != 0); + } + if (ImGui::IsItemActivated()) { + app.filter_dropdown_open = (app.filter_query_buf[0] != 0); + } + + // Dropdown bajo el input mientras hay texto. + if (app.filter_dropdown_open && !app.filter_hits.empty()) { + ImVec2 anchor = ImGui::GetItemRectMin(); + anchor.y = ImGui::GetItemRectMax().y + 2.0f; + ImGui::SetNextWindowPos(anchor); + ImGui::SetNextWindowSize(ImVec2(360.0f, 0.0f)); + ImGui::SetNextWindowBgAlpha(0.97f); + ImGuiWindowFlags wf = ImGuiWindowFlags_NoTitleBar + | ImGuiWindowFlags_NoResize + | ImGuiWindowFlags_NoMove + | ImGuiWindowFlags_NoSavedSettings + | ImGuiWindowFlags_AlwaysAutoResize + | ImGuiWindowFlags_NoFocusOnAppearing; + if (ImGui::Begin("##filter_dropdown", nullptr, wf)) { + for (const auto& h : app.filter_hits) { + char label[256]; + std::snprintf(label, sizeof(label), "%s [%s]##h_%s", + h.name.c_str(), + h.type_ref.empty() ? "?" : h.type_ref.c_str(), + h.id.c_str()); + if (ImGui::Selectable(label)) { + // Resolver a node_idx y pedir centrado. + uint64_t hh = fnv1a64_id(h.id.c_str()); + int idx = app.graph + ? app.graph->find_node_by_user_data(hh) : -1; + if (idx >= 0) { + app.filter_focus_target = idx; + if (app.viewport) { + graph_viewport_clear_selection(*app.graph, + *app.viewport); + graph_viewport_add_to_selection(*app.graph, + *app.viewport, idx); + } + } + app.filter_dropdown_open = false; + } + } + } + ImGui::End(); + } + + // Chips de tags activos. + for (size_t i = 0; i < app.filter_tags.size();) { + ImGui::SameLine(); + char chip[96]; + std::snprintf(chip, sizeof(chip), TI_TAG " %s " TI_X "##chip_%zu", + app.filter_tags[i].c_str(), i); + if (button(chip, ButtonVariant::Subtle)) { + app.filter_tags.erase(app.filter_tags.begin() + i); + app.filter_dirty = true; + continue; + } + ++i; + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(120); + ImGuiInputTextFlags tf = ImGuiInputTextFlags_EnterReturnsTrue; + if (ImGui::InputTextWithHint("##filtertag", "+ tag", + app.filter_tag_input, + sizeof(app.filter_tag_input), tf)) { + if (app.filter_tag_input[0]) { + views_filter_add_tag(app, app.filter_tag_input); + app.filter_tag_input[0] = 0; + } + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(96); + const char* mode_items[] = { "Highlight", "Hide" }; + int m = app.filter_mode; + if (ImGui::Combo("##fmode", &m, mode_items, 2)) { + if (m != app.filter_mode) { + app.filter_mode = m; + app.filter_dirty = true; + } + } + ImGui::SameLine(); + bool can_clear = views_filter_active(app); + if (!can_clear) ImGui::BeginDisabled(); + if (button(TI_FILTER_OFF " Clear", ButtonVariant::Subtle)) { + views_filter_clear(app); + } + if (!can_clear) ImGui::EndDisabled(); + } + toolbar_separator(); + ImGui::TextUnformatted("Layout:"); ImGui::SameLine(); ImGui::SetNextItemWidth(140); @@ -788,16 +1020,22 @@ void views_inspector(AppState& app) { 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"; + bool removed = false; if (ImGui::SmallButton(lbl.c_str())) { app.insp_tags.erase(app.insp_tags.begin() + i); - ImGui::PopStyleColor(3); - ImGui::PopID(); + removed = true; any_change = true; - --i; - continue; + } else if (ImGui::IsItemHovered() + && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + // Right-click anade el tag como chip del filtro (issue 0009). + views_filter_add_tag(app, app.insp_tags[i].c_str()); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("click: quitar | right-click: filtrar por tag"); } ImGui::PopStyleColor(3); ImGui::PopID(); + if (removed) { --i; continue; } if ((i + 1) % 4 != 0) ImGui::SameLine(); } if (!app.insp_tags.empty()) ImGui::NewLine(); diff --git a/views.h b/views.h index 89fb16c..5c101d2 100644 --- a/views.h +++ b/views.h @@ -136,6 +136,18 @@ struct AppState { // Caches refrescadas tras cargar grafo o tras Save. std::vector insp_tag_suggestions; std::vector insp_type_options; + + // ---- Filtros y busqueda FTS5 (issue 0009) ------------------------------ + // Modos: 0 = highlight (no-match dimmed), 1 = hide (no-match invisible). + enum FilterMode { FM_HIGHLIGHT = 0, FM_HIDE = 1 }; + int filter_mode = FM_HIGHLIGHT; + char filter_query_buf[128] = {}; + std::vector filter_tags; // chips activos + std::vector filter_hits; // dropdown FTS (max 20) + bool filter_dropdown_open = false; + bool filter_dirty = false; // pide reapply + int filter_focus_target = -1; // node_idx a centrar + char filter_tag_input[64] = {}; // input de chip nuevo }; // Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout). @@ -193,4 +205,23 @@ EntityRecord views_inspector_build_record(const AppState& app); // al cambiar de proyecto. void views_inspector_clear_draft(AppState& app); +// ---- Filter helpers (issue 0009) ----------------------------------------- + +// True si el filtro tiene query no vacia o al menos un tag activo. +bool views_filter_active(const AppState& app); + +// Anade un tag como chip si no existe ya. Marca filter_dirty = true. +void views_filter_add_tag(AppState& app, const char* tag); + +// Limpia query + tags y marca filter_dirty. +void views_filter_clear(AppState& app); + +// Reaplica el filtro al grafo: recompute la mascara de visibilidad/alpha +// segun filter_query, filter_tags, filter_mode. Llama a la BD si hace +// falta (FTS + tags). Resetea color_override a 0 en los nodos que pasan +// el filtro y aplica un alpha bajo en los que no (modo highlight) o limpia +// NF_VISIBLE (modo hide). Tambien refresca filter_hits para el dropdown +// con max 20 resultados ordenados por rank. +void views_filter_apply(AppState& app); + } // namespace ge