feat(filter): tag chips + FTS5 search en toolbar (issue 0009)

- entity_ops: entity_search_fts (bm25, prefix tokens) + entity_list_by_tags (AND).
- AppState: filter_query_buf, filter_tags, filter_mode (highlight/hide), hits cache.
- views_filter_apply: combina tipos visibles + match-set (FTS query AND tags),
  highlight = color_override con alpha=0x40, hide = clear NF_VISIBLE.
- toolbar: input search + dropdown (max 20, click centra y selecciona),
  chips de tags con boton X, input para anadir tag, combo Highlight/Hide,
  Clear filter.
- Inspector: right-click sobre tag chip lo anade al filtro.
- main.cpp: reapply en cada frame si filter_dirty; cam_x/y al focus_target.
This commit is contained in:
2026-05-01 00:23:26 +02:00
parent e49cef5cb3
commit 54aba71bf5
6 changed files with 440 additions and 14 deletions
+120
View File
@@ -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<EntityHit>* 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<std::string>& tags,
std::vector<std::string>* 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
// ----------------------------------------------------------------------------