feat(0035a): tipo Group + columna group_id en entities

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
This commit is contained in:
2026-05-03 14:23:23 +02:00
parent b0706b71c0
commit fc4f0824da
11 changed files with 559 additions and 8 deletions
+14
View File
@@ -129,6 +129,20 @@ entities:
- { name: text_length, type: int } - { name: text_length, type: int }
- { name: lang, type: string } - { 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 # Nodo tabla — cuadrado (regla de forma). Issue 0010: contenedor con
# filas que son nodos del grafo. # filas que son nodos del grafo.
- name: Table - name: Table
+129
View File
@@ -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 <source_specific>` 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 = <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/<project>/<app>.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`.
+56
View File
@@ -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 <ops.db> ".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).
@@ -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<entity_id, bool>` 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).
+67
View File
@@ -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: <query> (<N>)'`, `metadata` que incluya
`enricher='web_search'`, `query`, `count=N`, `batch_id=<UUID>`.
- 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=<id_group>`
y `batch_id=<UUID>` 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).
+57
View File
@@ -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 == <ese
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: <name>` 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: <name> (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.
+66
View File
@@ -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/<id>/manifest.yaml` ya lee campos top-level. Anadir
soporte para `auto_group_threshold: <int>` 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).
+18
View File
@@ -508,6 +508,15 @@ static bool switch_to_project(const std::string& slug) {
apply_project_paths(slug); apply_project_paths(slug);
ge::views_inspector_clear_draft(g_app); ge::views_inspector_clear_draft(g_app);
g_app.parsed_types = ge::ParsedTypes{}; 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())) { if (!ge::layout_store_open(g_layout_db_path.c_str())) {
std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n", std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
g_layout_db_path.c_str()); g_layout_db_path.c_str());
@@ -1949,6 +1958,15 @@ int main(int argc, char** argv) {
} }
apply_project_paths(target); 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::layout_store_open(g_layout_db_path.c_str());
ge::project_settings_touch(target.c_str()); ge::project_settings_touch(target.c_str());
load_input(); load_input();
+71
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS entities (
source TEXT NOT NULL DEFAULT 'graph_explorer', source TEXT NOT NULL DEFAULT 'graph_explorer',
metadata TEXT NOT NULL DEFAULT '{}', metadata TEXT NOT NULL DEFAULT '{}',
notes 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')), 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')) 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; 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<const char*>(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 // project_create
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
+11
View File
@@ -72,6 +72,17 @@ bool project_create(const char* slug, std::string* error_msg);
// Idempotente: no-op si projects/ ya existe. // Idempotente: no-op si projects/ ya existe.
bool projects_migrate_legacy_layout(); 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: // Lee/escribe `graph_explorer.ini` (junto al exe). Formato:
// last_active = <slug> // last_active = <slug>
// recent = slug1,slug2,slug3 // recent = slug1,slug2,slug3
+10 -8
View File
@@ -571,18 +571,20 @@ std::vector<uint16_t> apply_types_yaml(GraphData& graph, const ParsedTypes& type
} }
} }
// Regla de forma: todo nodo es circulo EXCEPTO el tipo "Table" (issue // Regla de forma: todo nodo es circulo EXCEPTO los tipos contenedores
// 0010 — nodo tabla cuadrado contenedor). Sobreescribe lo que diga el // "Table" (issue 0010) y "Group" (issue 0035), que son cuadrados.
// yaml: se aplica en cada reload, por lo que ediciones futuras desde el // Sobreescribe lo que diga el yaml: se aplica en cada reload, por lo
// Type Editor no rompen la convencion. Tambien forzamos un tamano // que ediciones futuras desde el Type Editor no rompen la convencion.
// notablemente mayor (32 px world) para que la diferencia visual con // Tambien forzamos un tamano notablemente mayor (32 px world) para que
// un nodo normal sea evidente. // la diferencia visual con un nodo normal sea evidente.
for (int i = 0; i < graph.type_count; ++i) { for (int i = 0; i < graph.type_count; ++i) {
EntityType& et = graph.types[i]; EntityType& et = graph.types[i];
bool is_table = et.name && (eq_ci(et.name, std::string("Table")) bool is_table = et.name && (eq_ci(et.name, std::string("Table"))
|| eq_ci(et.name, std::string("table"))); || eq_ci(et.name, std::string("table")));
et.shape = is_table ? SHAPE_SQUARE : SHAPE_CIRCLE; bool is_group = et.name && (eq_ci(et.name, std::string("Group"))
if (is_table) { || 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; et.default_size = 8.0f;
} }
} }