From fc4f0824da69a2793ee84e8d81a9ef50dad48dd2 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 3 May 2026 14:23:23 +0200 Subject: [PATCH] feat(0035a): tipo Group + columna group_id en entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbing para issue 0035 — agrupacion de resultados de enrichers cuando exceden umbral. Sin cambios visibles para el usuario todavia. - Migracion idempotente: ALTER TABLE entities ADD COLUMN group_id si no existe (detectado via PRAGMA table_info). Se ejecuta al abrir el proyecto en switch_to_project y en el bootstrap inicial. - Tipo Group en examples/types.yaml (template) y en el types.yaml del proyecto default activo en Windows. - shape=square (regla en types_registry.cpp extendida a Group), color=#94A3B8, icon=ti-stack-2. - Fields: name (req), count (int), enricher (string), batch_id (string). Refs: issues/0035a-group-type-and-schema.md --- examples/types.yaml | 14 ++ issues/0035-grouping-large-result-sets.md | 129 ++++++++++++++++++ issues/0035a-group-type-and-schema.md | 56 ++++++++ .../0035b-renderer-hides-grouped-children.md | 60 ++++++++ issues/0035c-web-search-creates-groups.md | 67 +++++++++ issues/0035d-tableview-drill-in.md | 57 ++++++++ issues/0035e-polish-and-tests.md | 66 +++++++++ main.cpp | 18 +++ project_manager.cpp | 71 ++++++++++ project_manager.h | 11 ++ types_registry.cpp | 18 +-- 11 files changed, 559 insertions(+), 8 deletions(-) create mode 100644 issues/0035-grouping-large-result-sets.md create mode 100644 issues/0035a-group-type-and-schema.md create mode 100644 issues/0035b-renderer-hides-grouped-children.md create mode 100644 issues/0035c-web-search-creates-groups.md create mode 100644 issues/0035d-tableview-drill-in.md create mode 100644 issues/0035e-polish-and-tests.md diff --git a/examples/types.yaml b/examples/types.yaml index a6a17b9..7e4f882 100644 --- a/examples/types.yaml +++ b/examples/types.yaml @@ -129,6 +129,20 @@ entities: - { name: text_length, type: int } - { name: lang, type: string } + # Nodo grupo — cuadrado (regla de forma). Issue 0035: contenedor para + # agrupar resultados de enrichers cuando exceden el umbral. Los hijos + # son entidades reales con `group_id` apuntando al Group. + - name: Group + color: "#94A3B8" + icon: ti-stack-2 + shape: square + principal_field: name + fields: + - { name: name, type: string, required: true } + - { name: count, type: int } + - { name: enricher, type: string } + - { name: batch_id, type: string } + # Nodo tabla — cuadrado (regla de forma). Issue 0010: contenedor con # filas que son nodos del grafo. - name: Table diff --git a/issues/0035-grouping-large-result-sets.md b/issues/0035-grouping-large-result-sets.md new file mode 100644 index 0000000..c2049aa --- /dev/null +++ b/issues/0035-grouping-large-result-sets.md @@ -0,0 +1,129 @@ +--- +id: 0035 +title: Agrupar resultados de enrichers cuando exceden un umbral — nodos `Group` cuadrados expandibles +status: pending +priority: high +created: 2026-05-03 +depends_on: [0026] +--- + +## Contexto + +Hoy un enricher como `web_search` con `limit=200`, `extract_links` sobre una pagina densa, o un futuro `gliner_extract` sobre un texto largo, vomitan N nodos sueltos en el viewport. Con N grande (cientos, miles) el grafo se vuelve ilegible: cluster apretado de bolitas anonimas alrededor del source, layouts saturados, panel Inspector inservible. + +Ademas, queremos poder llegar a **millones de nodos** sin que la app se ahogue al renderizar — no porque los necesitemos todos en pantalla a la vez, sino porque la fase de investigacion OSINT genuinamente produce esos volumenes (todos los enlaces de un sitio, todos los IoCs de un dump de logs, todos los followers de una cuenta...). + +Solucion: cuando un enricher produce >= N hijos (default 50, override en manifest), creamos un nodo cuadrado `Group` que los contiene. El renderer dibuja solo el cuadrado por defecto; los hijos se materializan en pantalla cuando el usuario "entra" al grupo. + +## Decisiones tomadas + +### 1. Storage — opcion C (hibrida) + +Los hijos **son entidades reales** en `operations.db`, no un blob serializado. Cada uno lleva un campo `group_id` que apunta a la entidad `Group` contenedora. El renderer filtra al cargar: + +``` +mostrar nodo si nodo.group_id IS NULL OR group(nodo.group_id).expanded +``` + +Beneficios: +- FTS5 sigue encontrando el hijo aunque este colapsado. +- Las relaciones cruzadas (un hijo de un grupo conectado a un nodo fuera del grupo) son aristas reales. +- La expansion es perezosa: al colapsar, los hijos se descargan del grafo en RAM (siguen en BD). + +### 2. Umbral + +- **Default global** (50) configurable en settings. +- **Override por enricher**: el manifest puede declarar `auto_group_threshold: 100` (o el valor que tenga sentido para ese flujo concreto). +- Sin override por ejecucion (de momento) — el modal de configuracion del enricher no incluye un checkbox "agrupar". Lo añadimos despues si hay friccion. + +### 3. Modo "Twitter / Reddit" + +Cuando el resultado excede el umbral, NO metemos los N hijos en el grupo de golpe. Estilo timeline: + +- Los primeros **K hijos** (K = 10-20 configurable, default 10) van **sueltos** colgando del source con relacion normal — el usuario los ve como preview directo. +- Los **N - K restantes** van dentro de un nodo `Group` que cuelga del source con la **misma relacion** (ej: `SEARCH_RESULT_OF`), etiquetado `+ {N-K} mas`. + +Por que: muchas veces los primeros resultados son los mas relevantes (DDG ranking, anchors prioritarios). El usuario ve lo importante sin friccion; el resto queda accesible pero compactado. Si el usuario quiere todo, abre el grupo. + +### 4. Doble click en el grupo → vista de tabla + +El grupo NO comparte el comportamiento del doble-click de un nodo normal (que abre Notes). En su lugar: + +- Doble click en un `Group` cuadrado → abre la **vista de tabla** (`tableview`) listando todos los hijos con sus columnas (id, name, type_ref, status, updated_at, ...) + filtros y busqueda. +- Desde esa tabla el usuario puede: + - Seleccionar filas y **promover** los nodos seleccionados (sacarlos del grupo y plantarlos como nodos sueltos en el canvas — `group_id = NULL`). + - **Re-agrupar** un subconjunto por tipo: seleccionar varios `Url` y "agrupar por type_ref" crea un sub-grupo `Url (X)` dentro del actual. +- Las **relaciones nunca se pierden**: agrupar/promover solo toca `group_id`, no las filas de `relations`. Un hijo dentro de un grupo sigue teniendo sus aristas reales en BD; lo unico que cambia es como las dibujamos (ver decision 5). + +Asi el grupo es un *contenedor de organizacion*, no una nueva identidad — promover y reagrupar son baratos. + +### 5. Aristas — v1 simple, sin agregacion + +Mientras el grupo este colapsado: +- Aristas **internas** (entre dos hijos del mismo grupo): se ocultan. +- Aristas **cruzando el limite** (hijo del grupo ↔ nodo fuera del grupo): se dibujan tal cual, conectando el nodo externo al grupo cuadrado (la coordenada del extremo "interno" se reemplaza por la del cuadrado). Una linea por arista, sin sumar pesos ni colapsar duplicados. + +Cuando el grupo esta expandido, las aristas se dibujan a sus extremos reales (entre hijos individuales). + +Implicacion visual: si 87 hijos del grupo tienen cada uno una relacion `BELONGS_TO` con un mismo `Domain` externo, ves 87 lineas dibujadas sobre el cuadrado → maraña. Lo aceptamos para v1; la agregacion con peso (1 sola linea con grosor proporcional a 87) queda para una v2. + +### 6. Cascada y multi-tipo — un enricher, un (o varios) grupos planos + +Caso: tengo un grupo "web_search: tomate" con 87 Urls. Lanzo `extract_links` sobre **todos**. Cada Url devuelve hasta 50 enlaces. Resultado bruto: hasta 4350 nodos nuevos. ¿Como los organizamos? + +**Decision: A flat con procedencia, partido por tipo cuando el enricher emite varios.** + +Reglas: + +- **Una ejecucion produce 1 grupo por tipo de salida** que exceda el umbral. + - Single-type (`extract_links` → todo Url): 1 grupo. + - Multi-type (`extract_text_entities` → Email + IP + Domain + ...): hasta N grupos, uno por tipo. Los tipos que individualmente NO superan el umbral quedan sueltos. +- **Cada grupo cuelga del trigger** con la **misma relacion** que tendrian sus hijos individuales (`EXTRACTED_FROM`, `LINKS_TO`, `SEARCH_RESULT_OF`, etc.). Si el trigger es a su vez un grupo (cascada masiva), el nuevo grupo cuelga del grupo origen. +- **Procedencia: la relacion ya la lleva**. El hijo tiene su `EXTRACTED_FROM ` real en `relations` — no se duplica como `metadata.source_node_id`. El filtro "muestrame los hijos que vienen de X" en tableview es una query a `relations`. +- **`batch_id` en metadata** del grupo y de cada hijo: UUID compartido por todos los nodos de la misma ejecucion (incluso si la salida se parte en varios grupos + nodos sueltos). Sirve para deshacer/filtrar/exportar la operacion completa. +- **Iconografia del grupo**: hereda color e icono del tipo que contiene (si es homogeneo). Cuadrado con shape de grupo + glyph del tipo. El usuario lee el canvas sin expandir. +- **Preview Twitter/Reddit aplica por tipo**: para cada tipo que excede umbral, los primeros K hijos quedan sueltos colgando del trigger; el resto va al grupo de ese tipo. +- **Excepcion a la regla 5 de aristas**: cuando ambos extremos caen en grupos colapsados (posiblemente el mismo), dibujamos UNA linea por par de grupos. Sin peso visual ni grosor — solo deduplicacion para evitar maranias topologicas. + +Por que A con esta forma: + +- Procedencia via filtro (queryable por relacion existente), no via estructura forzada del grafo. +- No hay problema de umbral marginal: la cascada en bloque siempre evalua el total contra el umbral, no source-por-source. +- Particion por tipo recobra significado visual del cuadrado (color/icono del contenido). +- "Extraer subset por source X" se queda como feature de fase 2 cuando madure la seleccion en tableview — el dato ya esta en BD desde el principio. + +Caveat aceptado: con tableview filtrando por `source_node_id`, el usuario tiene que pedirlo explicitamente para ver subsets. La estructura por defecto es plana por tipo. + +## Plan por fases + +### Fase 1 — MVP, un solo enricher + +- Tipo `Group` en `examples/types.yaml` (square, icono `ti-stack-2` o `ti-folder`, color distintivo). +- Columna `group_id TEXT` añadida al schema de `entities` (nullable). +- Aplicar solo a `web_search`: si `len(results) >= threshold`, crear `Group` + insertar los `N - K` restantes con `group_id` apuntando al grupo. +- Renderer ignora hijos con `group_id != NULL` salvo que el grupo este expandido (flag en RAM, no persistido aun). +- Doble click en `Group` → abre tableview filtrada por `group_id = `. +- Sin promote/regroup todavia. Sin cascada. Sin agregacion de aristas. + +Goal: validar end-to-end con un caso real antes de generalizar. + +### Fase 2 — generalizar + +- Aplicar a los 5 enrichers de sistema. Manifests declaran `auto_group_threshold`. +- Promote desde tableview (mover hijos fuera del grupo). +- Re-agrupar por tipo desde tableview. +- Persistir el flag `expanded` por grupo (en `local_files//.db`, no en operations.db). +- Cascada (decision 6): definir e implementar. + +### Fase 3 — escala y pulido + +- Layouts internos del grupo cuando esta expandido (los hijos se posicionan en el "area" del cuadrado, no fuera). +- Agregacion de aristas con peso visual. +- Drill-in con breadcrumb (`tomate / search results / domain X`) como vista alternativa al tableview. +- Exportar subgrafos respetando jerarquia de grupos (issue 0024). + +## Notas tecnicas (sin codigo) + +- El renderer carga TODA `entities` hoy. Con millones de hijos, eso se rompe — fase 2/3 requiere cambiar el load para que filtre con `WHERE group_id IS NULL OR group_id IN (lista expandida)` desde la query. +- El layout `place_orphans_near_neighbors` ya cluster bien los hermanos de un mismo anchor (cambio reciente). Eso sigue valiendo: un `Group` colgando del source es un orphan mas que se posiciona cerca del padre. +- El `tableview` ya existe (issue 0010-0011), solo hay que poder abrirlo filtrado por `group_id`. diff --git a/issues/0035a-group-type-and-schema.md b/issues/0035a-group-type-and-schema.md new file mode 100644 index 0000000..cda9c30 --- /dev/null +++ b/issues/0035a-group-type-and-schema.md @@ -0,0 +1,56 @@ +--- +id: 0035a +title: Tipo Group + columna group_id en entities (plumbing) +status: pending +priority: high +created: 2026-05-03 +parent: 0035 +--- + +## Objetivo + +Plumbing invisible al usuario. Tras esta tarea no hay cambio visual; solo +infraestructura preparada para que las fases siguientes puedan crear +grupos sin tocar mas schema. + +## Cambios + +1. **Schema `entities`**: anadir columna `group_id TEXT NULL` (sin + indice todavia, sin FK constraint — la integridad la mantiene el + codigo). +2. **Migracion idempotente**: cuando la app abre una `operations.db` + existente sin esa columna, `ALTER TABLE entities ADD COLUMN group_id + TEXT` se ejecuta una vez. Detectar via `PRAGMA table_info(entities)` + antes de alterar. +3. **Tipo `Group`** en: + - `examples/types.yaml` (template para proyectos nuevos) + - `local_files/projects/default/types.yaml` del install Windows + (para que el proyecto activo del usuario lo vea) +4. **Definicion del tipo**: + - shape: `square` + - color: `#94A3B8` (slate neutro) + - icon: `ti-stack-2` + - principal_field: `name` + - fields: `name` (string, required), `count` (int), `enricher` (string), + `batch_id` (string) + +## Acceptance criteria + +- `sqlite3 ".schema entities"` muestra `group_id TEXT`. +- Abrir una BD vieja (sin columna) la migra al primer arranque sin + perder datos. +- `Group` aparece en el Type Editor con sus fields y atributos visuales. +- Tests pytest siguen verdes en WSL y Windows (32 / 21 + 11 skipped). +- App sigue arrancando y abriendo proyectos existentes sin warnings ni + errores de schema. + +## TBD + +Branch `issue/0035a-group-type-and-schema` en el sub-repo del app, +merge `--no-ff` a `master` con tests verdes. + +## Out of scope + +- Crear entidades de tipo Group (eso es 0035c). +- Filtrar el renderer (eso es 0035b). +- Doble click en Group (eso es 0035d). diff --git a/issues/0035b-renderer-hides-grouped-children.md b/issues/0035b-renderer-hides-grouped-children.md new file mode 100644 index 0000000..02cc6c0 --- /dev/null +++ b/issues/0035b-renderer-hides-grouped-children.md @@ -0,0 +1,60 @@ +--- +id: 0035b +title: Renderer oculta hijos de grupos colapsados + dedup de aristas grupo-a-grupo +status: pending +priority: high +created: 2026-05-03 +parent: 0035 +depends_on: [0035a] +--- + +## Objetivo + +El renderer aprende a esconder entidades cuyo `group_id` apunta a un +grupo colapsado, y a deduplicar aristas que cruzan dos grupos +colapsados a una sola linea por par de grupos. Sin este paso, los +grupos creados por enrichers (0035c) seguirian mostrando todos sus +hijos en el canvas. + +## Cambios + +1. **Estado de expansion en RAM**: `unordered_map` en + AppState (`group_expanded`). Default: vacio (todos los grupos + colapsados al arranque). No persiste entre sesiones (fase 1). +2. **Filtro al cargar el grafo** (`graph_load_from_operations` o + wrapper en el app): tras leer entities de la BD, descartar las que + tengan `group_id != NULL` y cuyo grupo padre no este expandido. Las + aristas cuyos extremos esten descartados tambien se omiten. +3. **Deduplicacion de aristas inter-grupo**: cuando ambos extremos de + una arista caen dentro de grupos colapsados (incluso el mismo), + colapsamos el conjunto a UNA linea por par `(group_a, group_b)`. + Sin peso visual, sin grosor variable. Solo deduplicacion topologica. +4. **Centralizar el filtro**: aplicarlo dentro del loader (no en cada + caller). Hoy el grafo se recarga desde toolbar reload, dirty_counter + de jobs, chat mutations marker, switch project — el filtro debe + funcionar en TODAS estas rutas. + +## Acceptance criteria + +- Insertando manualmente en BD un `Group` + un Url con `group_id` que + apunte a el, recargar el grafo muestra solo el cuadrado, no el Url. +- Si el Url tiene una arista a un Domain externo, la arista no se + dibuja al Url oculto pero SI se dibuja al cuadrado del grupo. +- Si dos Urls dentro del mismo grupo tienen aristas a un Domain + externo, se dibuja UNA sola arista del grupo al Domain (dedup). +- Con `group_expanded[id] = true` (manualmente seteado a efectos de + test, no hay UI todavia), los hijos vuelven a aparecer y las + aristas se dibujan a su extremo real. +- Tests pytest verdes. + +## TBD + +Branch `issue/0035b-renderer-hides-grouped-children` en el sub-repo, +merge `--no-ff` a master con tests verdes. + +## Out of scope + +- UI para expandir/colapsar grupo (drilling se hace via tableview en + 0035d, no expandiendo en canvas). +- Layouts internos del grupo expandido (fase 3). +- Agregacion de aristas con peso visual (fase 3). diff --git a/issues/0035c-web-search-creates-groups.md b/issues/0035c-web-search-creates-groups.md new file mode 100644 index 0000000..1d9d6b3 --- /dev/null +++ b/issues/0035c-web-search-creates-groups.md @@ -0,0 +1,67 @@ +--- +id: 0035c +title: web_search crea Group cuando excede umbral (Twitter/Reddit preview) +status: pending +priority: high +created: 2026-05-03 +parent: 0035 +depends_on: [0035a, 0035b] +--- + +## Objetivo + +Que `web_search` deje de vomitar 100+ Urls sueltos. Cuando el numero +de resultados excede el umbral, crea un nodo `Group` que contiene la +mayoria; los primeros K quedan sueltos como preview directo del +source. + +## Cambios + +1. **Threshold**: leer `auto_group_threshold` del manifest si esta + presente; fallback a constante global `50` (hardcoded por ahora; + settings UI viene en fase 2). Para `web_search/manifest.yaml` no + declaramos override — usa el default. +2. **Preview K**: constante global `10` (primeros K resultados quedan + sueltos colgando del source con `SEARCH_RESULT_OF` normal). +3. **Creacion del grupo en `run.py`**: si `len(results) >= threshold`: + - Insertar entidad `Group` con `type_ref='Group'`, + `name='web_search: ()'`, `metadata` que incluya + `enricher='web_search'`, `query`, `count=N`, `batch_id=`. + - Insertar relacion `SEARCH_RESULT_OF` desde el Group al source. + - Insertar los primeros K resultados como Urls sueltos + (comportamiento actual, con `batch_id` en metadata). + - Insertar los restantes N-K como Urls con `group_id=` + y `batch_id=` en metadata. Cada uno mantiene su relacion + individual `SEARCH_RESULT_OF` al source (NO al grupo — la + procedencia es la relacion real). +4. **batch_id**: `UUID4` generado al inicio del run, compartido por + todos los nodos creados en esa ejecucion (group + sueltos + hijos + del grupo). + +## Acceptance criteria + +- `web_search` con un mock que devuelve 5 resultados: NO crea Group. + Comportamiento actual. +- `web_search` con un mock que devuelve 100 resultados: crea 1 Group + + 10 Urls sueltos + 90 Urls con group_id. +- Todos los nodos creados (group + sueltos + agrupados) comparten + `metadata.batch_id`. +- En el canvas (con 0035b ya en master): aparecen 10 Urls + 1 cuadrado + Group colgando del source. Los 90 Urls dentro del Group estan + ocultos. +- Tests pytest nuevos: + - `test_web_search_below_threshold_no_group` + - `test_web_search_above_threshold_creates_group_and_preview` + - `test_web_search_batch_id_shared_across_outputs` +- Tests existentes siguen verdes. + +## TBD + +Branch `issue/0035c-web-search-creates-groups`, merge `--no-ff` con +tests verdes. + +## Out of scope + +- Aplicar a otros enrichers (fase 2). +- Cascada multi-tipo (decision 6, fase 2). +- Regroup/promote desde tableview (fase 2). diff --git a/issues/0035d-tableview-drill-in.md b/issues/0035d-tableview-drill-in.md new file mode 100644 index 0000000..19acbbd --- /dev/null +++ b/issues/0035d-tableview-drill-in.md @@ -0,0 +1,57 @@ +--- +id: 0035d +title: Doble click en Group abre tableview filtrado por group_id +status: pending +priority: high +created: 2026-05-03 +parent: 0035 +depends_on: [0035a, 0035b, 0035c] +--- + +## Objetivo + +Que el usuario pueda inspeccionar lo que hay dentro de un grupo. Doble +click sobre un nodo `Group` no abre la nota como un nodo normal, sino +una vista de tabla con sus hijos, busqueda y filtros. + +## Cambios + +1. **Discriminacion en `on_double_click_cb`**: al recibir el doble + click sobre un node_idx, leer su `type_ref`. Si es `Group`, en + lugar de poblar `note_node` y abrir el panel Note, setear los + triggers para abrir tableview filtrada por `group_id == `. +2. **Modo "filter by group" en tableview**: el panel ya tiene tabs + por type_ref y filtros por columna. Anadir un modo (flag en + AppState `table_filter_group_id: string`) que cuando esta seteado + limita las filas a `WHERE group_id = ?`. +3. **Header con breadcrumb minimo**: al abrir tableview por grupo, + mostrar arriba `Group: ` con el contador y un boton "Clear + group filter" que vuelve al modo libre. +4. **Persistencia (RAM only)**: el filtro se mantiene mientras el + panel esta abierto; al cerrar el panel se limpia. No persistir + en la BD del app. + +## Acceptance criteria + +- Doble click en un nodo `Url` o `Person` etc.: abre Note como + siempre (no cambio). +- Doble click en un nodo `Group`: NO abre Note. Abre Table con todos + los hijos cuyo `group_id` apunta al grupo. La cabecera dice + "Group: (N)". +- Clear group filter: vuelve al tableview general. +- Busqueda y tabs por tipo dentro del filtro siguen funcionando — el + filtro por group_id se compone con los demas. +- Tests pytest verdes (no requieren tests especificos de UI; basta + con que los tests existentes no rompan). + +## TBD + +Branch `issue/0035d-tableview-drill-in`, merge `--no-ff` con tests +verdes. + +## Out of scope + +- Promote (sacar nodo del grupo): fase 2. +- Re-agrupar por tipo desde tableview: fase 2. +- Layouts internos del grupo expandido en canvas: fase 3. diff --git a/issues/0035e-polish-and-tests.md b/issues/0035e-polish-and-tests.md new file mode 100644 index 0000000..23a5811 --- /dev/null +++ b/issues/0035e-polish-and-tests.md @@ -0,0 +1,66 @@ +--- +id: 0035e +title: Pulido del Group + tests cross-platform +status: pending +priority: medium +created: 2026-05-03 +parent: 0035 +depends_on: [0035c, 0035d] +--- + +## Objetivo + +Cierre de la fase 1: el cuadrado del grupo es visualmente informativo +(hereda iconografia del tipo mayoritario), el threshold se lee del +manifest si esta declarado, y la suite de tests cubre los caminos +nuevos en WSL y Windows. + +## Cambios + +1. **Iconografia heredada**: al construir el atlas / asignar visuales + a un nodo Group, mirar los `type_ref` distintos de sus hijos. Si + todos comparten un solo tipo, el cuadrado adopta el color y el + icono de ese tipo (manteniendo shape=square para distinguirlo de un + nodo individual). Si hay heterogeneidad, se queda con el visual + genericamente Group (slate + ti-stack-2). +2. **Threshold desde manifest**: el parser de + `enrichers//manifest.yaml` ya lee campos top-level. Anadir + soporte para `auto_group_threshold: ` y propagarlo al runtime + del enricher (lo ve via env var `_GROUP_THRESHOLD` o argumento + adicional, lo que sea menos invasivo). +3. **Tests pytest nuevos**: + - `test_group_inherits_visual_from_homogeneous_children`: crear + Group con 5 Urls como hijos, verificar que su color/icono + resuelve al de Url. + - `test_group_falls_back_to_generic_for_heterogeneous`: Group con + Url + Email, verificar que se queda con el visual genericamente + Group. + - `test_manifest_auto_group_threshold_override`: manifest con + `auto_group_threshold: 100`, run con 80 resultados no agrupa + (asumiendo default global 50, override a 100). + - `test_schema_migration_idempotent`: BD sin columna group_id + migra correctamente; segunda apertura no rompe. +4. **Validacion cross-platform**: la suite completa pasa en WSL + (32 + N nuevos) y en Windows (21 + N nuevos, mas los 11 saltados + conocidos). + +## Acceptance criteria + +- Group con 5 Urls homogeneos: cuadrado con color/icono de Url. +- Group con Url + Email mezclados: cuadrado slate generico. +- Override de threshold via manifest funciona. +- Migration idempotente: abrir BD ya migrada no genera warnings. +- Tests verdes en ambos OS. + +## TBD + +Branch `issue/0035e-polish-and-tests`, merge `--no-ff` con tests +verdes en ambos OS. + +## Out of scope + +- Cascada (decision 6 — fase 2). +- Aplicar grouping a `extract_links`, `fetch_webpage`, + `extract_text_entities` (fase 2). +- Promote/regroup desde tableview (fase 2). +- Persistir flag `expanded` por grupo (fase 2). diff --git a/main.cpp b/main.cpp index 0f44ba5..6c397a9 100644 --- a/main.cpp +++ b/main.cpp @@ -508,6 +508,15 @@ static bool switch_to_project(const std::string& slug) { apply_project_paths(slug); ge::views_inspector_clear_draft(g_app); g_app.parsed_types = ge::ParsedTypes{}; + // Migracion idempotente del schema (issue 0035a y siguientes). + { + std::string mig_err; + if (!ge::project_migrate_schema(g_input_path, &mig_err)) { + std::fprintf(stderr, + "[graph_explorer] project_migrate_schema('%s') failed: %s\n", + g_input_path.c_str(), mig_err.c_str()); + } + } 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()); @@ -1949,6 +1958,15 @@ int main(int argc, char** argv) { } apply_project_paths(target); + // Migracion idempotente del schema (issue 0035a y siguientes). + { + std::string mig_err; + if (!ge::project_migrate_schema(g_input_path, &mig_err)) { + std::fprintf(stderr, + "[graph_explorer] project_migrate_schema('%s') failed: %s\n", + g_input_path.c_str(), mig_err.c_str()); + } + } ge::layout_store_open(g_layout_db_path.c_str()); ge::project_settings_touch(target.c_str()); load_input(); diff --git a/project_manager.cpp b/project_manager.cpp index f7322e5..a4c938c 100644 --- a/project_manager.cpp +++ b/project_manager.cpp @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS entities ( source TEXT NOT NULL DEFAULT 'graph_explorer', metadata TEXT NOT NULL DEFAULT '{}', notes TEXT NOT NULL DEFAULT '', + group_id TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) ); @@ -300,6 +301,76 @@ static bool bootstrap_operations_db(const std::string& path, std::string* error_ return true; } +// ---------------------------------------------------------------------------- +// Migraciones idempotentes de operations.db existente +// ---------------------------------------------------------------------------- +// +// Cada migracion es un check + ALTER TABLE / CREATE ... condicional. Se +// ejecuta una vez al abrir un proyecto (project_migrate_schema). Detecta el +// estado actual via PRAGMA table_info y ejecuta solo lo que falta. No-op si +// la BD ya esta al dia. +// +// Issue 0035a: anade columna entities.group_id (TEXT NULL) si no existe. + +// Devuelve true si la columna `column` existe en la tabla `table`. +static bool table_has_column(sqlite3* db, const char* table, const char* column) { + std::string sql = "PRAGMA table_info("; + sql += table; + sql += ");"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK) { + return false; + } + bool found = false; + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char* name = sqlite3_column_text(stmt, 1); + if (name && std::strcmp(reinterpret_cast(name), column) == 0) { + found = true; + break; + } + } + sqlite3_finalize(stmt); + return found; +} + +bool project_migrate_schema(const std::string& path, std::string* error_msg) { + sqlite3* db = nullptr; + int rc = sqlite3_open_v2(path.c_str(), &db, + SQLITE_OPEN_READWRITE, nullptr); + if (rc != SQLITE_OK) { + if (error_msg) { + *error_msg = "sqlite3_open failed: "; + *error_msg += db ? sqlite3_errmsg(db) : sqlite3_errstr(rc); + } + if (db) sqlite3_close(db); + return false; + } + + // 0035a: entities.group_id + if (!table_has_column(db, "entities", "group_id")) { + char* errmsg = nullptr; + rc = sqlite3_exec(db, + "ALTER TABLE entities ADD COLUMN group_id TEXT", + nullptr, nullptr, &errmsg); + if (rc != SQLITE_OK) { + if (error_msg) { + *error_msg = "ALTER TABLE entities ADD COLUMN group_id failed: "; + *error_msg += errmsg ? errmsg : "(unknown)"; + } + if (errmsg) sqlite3_free(errmsg); + sqlite3_close(db); + return false; + } + if (errmsg) sqlite3_free(errmsg); + std::fprintf(stdout, + "[project_manager] migrated %s: ALTER TABLE entities ADD COLUMN group_id\n", + path.c_str()); + } + + sqlite3_close(db); + return true; +} + // ---------------------------------------------------------------------------- // project_create // ---------------------------------------------------------------------------- diff --git a/project_manager.h b/project_manager.h index 738d9b7..246121c 100644 --- a/project_manager.h +++ b/project_manager.h @@ -72,6 +72,17 @@ bool project_create(const char* slug, std::string* error_msg); // Idempotente: no-op si projects/ ya existe. bool projects_migrate_legacy_layout(); +// Migracion idempotente del schema de una operations.db existente. +// Detecta columnas/tablas ausentes via PRAGMA y aplica los ALTER TABLE +// minimos. Llamar al abrir un proyecto antes de cargar el grafo. +// +// Migraciones aplicadas: +// - 0035a: ALTER TABLE entities ADD COLUMN group_id TEXT (si falta) +// +// Devuelve true si la BD esta al dia (o ya lo estaba). Si falla, rellena +// `error_msg` y devuelve false. +bool project_migrate_schema(const std::string& path, std::string* error_msg); + // Lee/escribe `graph_explorer.ini` (junto al exe). Formato: // last_active = // recent = slug1,slug2,slug3 diff --git a/types_registry.cpp b/types_registry.cpp index 35d7418..45f5c8e 100644 --- a/types_registry.cpp +++ b/types_registry.cpp @@ -571,18 +571,20 @@ std::vector apply_types_yaml(GraphData& graph, const ParsedTypes& type } } - // Regla de forma: todo nodo es circulo EXCEPTO el tipo "Table" (issue - // 0010 — nodo tabla cuadrado contenedor). Sobreescribe lo que diga el - // yaml: se aplica en cada reload, por lo que ediciones futuras desde el - // Type Editor no rompen la convencion. Tambien forzamos un tamano - // notablemente mayor (32 px world) para que la diferencia visual con - // un nodo normal sea evidente. + // Regla de forma: todo nodo es circulo EXCEPTO los tipos contenedores + // "Table" (issue 0010) y "Group" (issue 0035), que son cuadrados. + // Sobreescribe lo que diga el yaml: se aplica en cada reload, por lo + // que ediciones futuras desde el Type Editor no rompen la convencion. + // Tambien forzamos un tamano notablemente mayor (32 px world) para que + // la diferencia visual con un nodo normal sea evidente. for (int i = 0; i < graph.type_count; ++i) { EntityType& et = graph.types[i]; bool is_table = et.name && (eq_ci(et.name, std::string("Table")) || eq_ci(et.name, std::string("table"))); - et.shape = is_table ? SHAPE_SQUARE : SHAPE_CIRCLE; - if (is_table) { + bool is_group = et.name && (eq_ci(et.name, std::string("Group")) + || eq_ci(et.name, std::string("group"))); + et.shape = (is_table || is_group) ? SHAPE_SQUARE : SHAPE_CIRCLE; + if (is_table || is_group) { et.default_size = 8.0f; } }