c9fd4aa84c
- Añade enricher.go + directorio enrichers/ para enriquecer entidades con fuentes externas. - Nuevos componentes frontend: IngestPanel (panel de ingesta de datos) y NodeContextMenu (menu contextual sobre nodos del grafo). - Retira SearchBar y lib/utils.ts; la busqueda se integra dentro de los paneles existentes. - Ajusta tipos (types.go, types.ts, wailsjs/go) y theming (postcss + app.css + Mantine). - Actualiza app.go y wails.json para exponer las nuevas capacidades. - Añade directorio projects/ con estado inicial. - Rebuild del frontend (dist actualizado).
798 lines
20 KiB
Go
798 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
ops "fn-registry/fn_operations"
|
|
"fn-registry/registry"
|
|
)
|
|
|
|
// App is the Wails-bound application struct.
|
|
type App struct {
|
|
ctx context.Context
|
|
mu sync.RWMutex
|
|
projectsDir string
|
|
registryRoot string
|
|
registryDB *registry.DB
|
|
db *ops.DB
|
|
currentProj string
|
|
}
|
|
|
|
// NewApp creates a new App instance.
|
|
func NewApp(projectsDir, registryRoot string) *App {
|
|
log.Printf("[NewApp] projectsDir=%s registryRoot=%s", projectsDir, registryRoot)
|
|
return &App{
|
|
projectsDir: projectsDir,
|
|
registryRoot: registryRoot,
|
|
}
|
|
}
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
log.Println("[startup] called")
|
|
|
|
// Open registry.db for type lookups and snapshots
|
|
regPath := filepath.Join(a.registryRoot, "registry.db")
|
|
log.Printf("[startup] opening registry.db at %s", regPath)
|
|
if db, err := registry.Open(regPath); err == nil {
|
|
a.registryDB = db
|
|
log.Println("[startup] registry.db opened OK")
|
|
} else {
|
|
log.Printf("[startup] WARNING: registry.db failed: %v", err)
|
|
}
|
|
|
|
// Ensure projects directory exists
|
|
log.Printf("[startup] ensuring projectsDir exists: %s", a.projectsDir)
|
|
if err := os.MkdirAll(a.projectsDir, 0o755); err != nil {
|
|
log.Printf("[startup] ERROR creating projectsDir: %v", err)
|
|
}
|
|
|
|
// List what's there already
|
|
entries, err := os.ReadDir(a.projectsDir)
|
|
if err != nil {
|
|
log.Printf("[startup] ERROR reading projectsDir: %v", err)
|
|
} else {
|
|
log.Printf("[startup] projectsDir has %d entries", len(entries))
|
|
for _, e := range entries {
|
|
log.Printf("[startup] - %s (dir=%v)", e.Name(), e.IsDir())
|
|
}
|
|
}
|
|
|
|
log.Println("[startup] done")
|
|
}
|
|
|
|
func (a *App) shutdown(_ context.Context) {
|
|
log.Println("[shutdown] called")
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db != nil {
|
|
a.db.Close()
|
|
log.Println("[shutdown] closed project db")
|
|
}
|
|
if a.registryDB != nil {
|
|
a.registryDB.Close()
|
|
log.Println("[shutdown] closed registry db")
|
|
}
|
|
}
|
|
|
|
// --- Projects ---
|
|
|
|
func (a *App) ListProjects() ([]ProjectInfo, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
log.Printf("[ListProjects] projectsDir=%s", a.projectsDir)
|
|
projects, err := listProjectDirs(a.projectsDir)
|
|
if err != nil {
|
|
log.Printf("[ListProjects] ERROR: %v", err)
|
|
return nil, err
|
|
}
|
|
log.Printf("[ListProjects] found %d projects", len(projects))
|
|
for _, p := range projects {
|
|
log.Printf("[ListProjects] - %s (entities=%d relations=%d)", p.Name, p.EntityCount, p.RelCount)
|
|
}
|
|
return projects, nil
|
|
}
|
|
|
|
func (a *App) CreateProject(name string) (ProjectInfo, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
name = strings.TrimSpace(name)
|
|
log.Printf("[CreateProject] name=%q projectsDir=%s", name, a.projectsDir)
|
|
|
|
if name == "" {
|
|
log.Println("[CreateProject] ERROR: empty name")
|
|
return ProjectInfo{}, fmt.Errorf("project name cannot be empty")
|
|
}
|
|
|
|
targetDir := filepath.Join(a.projectsDir, name)
|
|
log.Printf("[CreateProject] targetDir=%s", targetDir)
|
|
|
|
if err := createProject(a.projectsDir, name); err != nil {
|
|
log.Printf("[CreateProject] ERROR: %v", err)
|
|
return ProjectInfo{}, err
|
|
}
|
|
|
|
// Verify it was created
|
|
dbPath := filepath.Join(targetDir, "operations.db")
|
|
if _, err := os.Stat(dbPath); err != nil {
|
|
log.Printf("[CreateProject] WARNING: operations.db not found after create: %v", err)
|
|
} else {
|
|
log.Printf("[CreateProject] OK: operations.db exists at %s", dbPath)
|
|
}
|
|
|
|
log.Printf("[CreateProject] success: %s", name)
|
|
return ProjectInfo{Name: name}, nil
|
|
}
|
|
|
|
func (a *App) SwitchProject(name string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
log.Printf("[SwitchProject] name=%q (current=%q)", name, a.currentProj)
|
|
|
|
if a.db != nil {
|
|
a.db.Close()
|
|
a.db = nil
|
|
log.Println("[SwitchProject] closed previous db")
|
|
}
|
|
|
|
dbPath := filepath.Join(a.projectsDir, name, "operations.db")
|
|
log.Printf("[SwitchProject] opening %s", dbPath)
|
|
|
|
db, err := ops.Open(dbPath)
|
|
if err != nil {
|
|
log.Printf("[SwitchProject] ERROR: %v", err)
|
|
return fmt.Errorf("opening project %s: %w", name, err)
|
|
}
|
|
|
|
a.db = db
|
|
a.currentProj = name
|
|
log.Printf("[SwitchProject] OK: now on project %s", name)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) DeleteProject(name string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
log.Printf("[DeleteProject] name=%q", name)
|
|
|
|
if a.currentProj == name && a.db != nil {
|
|
a.db.Close()
|
|
a.db = nil
|
|
a.currentProj = ""
|
|
log.Println("[DeleteProject] closed active project db")
|
|
}
|
|
|
|
err := deleteProject(a.projectsDir, name)
|
|
if err != nil {
|
|
log.Printf("[DeleteProject] ERROR: %v", err)
|
|
} else {
|
|
log.Printf("[DeleteProject] OK: deleted %s", name)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *App) GetCurrentProject() string {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
log.Printf("[GetCurrentProject] -> %q", a.currentProj)
|
|
return a.currentProj
|
|
}
|
|
|
|
// --- Entities ---
|
|
|
|
func (a *App) ListEntities() ([]ops.Entity, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
log.Println("[ListEntities] ERROR: no project selected")
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
entities, err := a.db.ListEntities("", "")
|
|
if err != nil {
|
|
log.Printf("[ListEntities] ERROR: %v", err)
|
|
} else {
|
|
log.Printf("[ListEntities] found %d entities", len(entities))
|
|
}
|
|
return entities, err
|
|
}
|
|
|
|
func (a *App) GetEntity(id string) (*ops.Entity, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[GetEntity] id=%s", id)
|
|
return a.db.GetEntity(id)
|
|
}
|
|
|
|
func (a *App) AddEntity(input EntityInput) (string, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
log.Println("[AddEntity] ERROR: no project selected")
|
|
return "", fmt.Errorf("no project selected")
|
|
}
|
|
|
|
id := makeEntityID(input.Name, input.TypeRef)
|
|
log.Printf("[AddEntity] name=%q typeRef=%q -> id=%s", input.Name, input.TypeRef, id)
|
|
now := time.Now()
|
|
|
|
e := &ops.Entity{
|
|
ID: id,
|
|
Name: input.Name,
|
|
TypeRef: input.TypeRef,
|
|
Status: ops.StatusActive,
|
|
Description: input.Description,
|
|
Domain: "osint",
|
|
Tags: input.Tags,
|
|
Source: "fuzzygraph",
|
|
Metadata: input.Metadata,
|
|
Notes: input.Notes,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
// Use InsertEntityWithSnapshot if registry is available
|
|
if a.registryDB != nil {
|
|
log.Println("[AddEntity] using InsertEntityWithSnapshot")
|
|
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
|
|
log.Printf("[AddEntity] ERROR: %v", err)
|
|
return "", err
|
|
}
|
|
} else {
|
|
log.Println("[AddEntity] using plain InsertEntity (no registry)")
|
|
if err := a.db.InsertEntity(e); err != nil {
|
|
log.Printf("[AddEntity] ERROR: %v", err)
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
log.Printf("[AddEntity] OK: %s", id)
|
|
return id, nil
|
|
}
|
|
|
|
func (a *App) UpdateEntity(id string, input EntityInput) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return fmt.Errorf("no project selected")
|
|
}
|
|
|
|
log.Printf("[UpdateEntity] id=%s", id)
|
|
|
|
existing, err := a.db.GetEntity(id)
|
|
if err != nil {
|
|
log.Printf("[UpdateEntity] ERROR getting: %v", err)
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
log.Printf("[UpdateEntity] ERROR: not found")
|
|
return fmt.Errorf("entity %s not found", id)
|
|
}
|
|
|
|
existing.Name = input.Name
|
|
existing.TypeRef = input.TypeRef
|
|
existing.Description = input.Description
|
|
existing.Tags = input.Tags
|
|
existing.Metadata = input.Metadata
|
|
existing.Notes = input.Notes
|
|
existing.UpdatedAt = time.Now()
|
|
|
|
if err := a.db.UpdateEntity(existing); err != nil {
|
|
log.Printf("[UpdateEntity] ERROR: %v", err)
|
|
return err
|
|
}
|
|
log.Printf("[UpdateEntity] OK: %s", id)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) DeleteEntity(id string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[DeleteEntity] id=%s", id)
|
|
return a.db.DeleteEntity(id)
|
|
}
|
|
|
|
// --- Relations ---
|
|
|
|
func (a *App) ListRelations() ([]ops.Relation, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
log.Println("[ListRelations] ERROR: no project selected")
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
relations, err := a.db.ListRelations("")
|
|
if err != nil {
|
|
log.Printf("[ListRelations] ERROR: %v", err)
|
|
} else {
|
|
log.Printf("[ListRelations] found %d relations", len(relations))
|
|
}
|
|
return relations, err
|
|
}
|
|
|
|
func (a *App) AddRelation(input RelationInputDTO) (string, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return "", fmt.Errorf("no project selected")
|
|
}
|
|
|
|
id := generateID()
|
|
log.Printf("[AddRelation] name=%q from=%s to=%s id=%s", input.Name, input.FromEntity, input.ToEntity, id)
|
|
now := time.Now()
|
|
|
|
r := &ops.Relation{
|
|
ID: id,
|
|
Name: input.Name,
|
|
FromEntity: input.FromEntity,
|
|
ToEntity: input.ToEntity,
|
|
Description: input.Description,
|
|
Purity: "impure",
|
|
Direction: ops.DirUnidirectional,
|
|
Weight: input.Weight,
|
|
Status: ops.RelImplemented,
|
|
Tags: input.Tags,
|
|
Notes: input.Notes,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := a.db.InsertRelation(r); err != nil {
|
|
log.Printf("[AddRelation] ERROR: %v", err)
|
|
return "", err
|
|
}
|
|
|
|
log.Printf("[AddRelation] OK: %s", id)
|
|
return id, nil
|
|
}
|
|
|
|
func (a *App) UpdateRelation(id string, input RelationInputDTO) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return fmt.Errorf("no project selected")
|
|
}
|
|
|
|
log.Printf("[UpdateRelation] id=%s", id)
|
|
|
|
existing, err := a.db.GetRelation(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
return fmt.Errorf("relation %s not found", id)
|
|
}
|
|
|
|
existing.Name = input.Name
|
|
existing.FromEntity = input.FromEntity
|
|
existing.ToEntity = input.ToEntity
|
|
existing.Description = input.Description
|
|
existing.Weight = input.Weight
|
|
existing.Tags = input.Tags
|
|
existing.Notes = input.Notes
|
|
existing.UpdatedAt = time.Now()
|
|
|
|
return a.db.UpdateRelation(existing)
|
|
}
|
|
|
|
func (a *App) DeleteRelation(id string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[DeleteRelation] id=%s", id)
|
|
return a.db.DeleteRelation(id)
|
|
}
|
|
|
|
// --- Graph ---
|
|
|
|
func (a *App) GetGraphData() (GraphData, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
log.Println("[GetGraphData] no project selected")
|
|
return GraphData{}, fmt.Errorf("no project selected")
|
|
}
|
|
data, err := buildGraphData(a.db)
|
|
if err != nil {
|
|
log.Printf("[GetGraphData] ERROR: %v", err)
|
|
} else {
|
|
log.Printf("[GetGraphData] nodes=%d edges=%d", len(data.Nodes), len(data.Edges))
|
|
}
|
|
return data, err
|
|
}
|
|
|
|
func (a *App) GetEntityNeighbors(entityID string, depth int) (GraphData, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return GraphData{}, fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[GetEntityNeighbors] entityID=%s depth=%d", entityID, depth)
|
|
return buildEgoGraph(a.db, entityID, depth)
|
|
}
|
|
|
|
func (a *App) GetFilteredGraph(typeFilters []string) (GraphData, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return GraphData{}, fmt.Errorf("no project selected")
|
|
}
|
|
|
|
log.Printf("[GetFilteredGraph] filters=%v", typeFilters)
|
|
|
|
full, err := buildGraphData(a.db)
|
|
if err != nil {
|
|
return GraphData{}, err
|
|
}
|
|
|
|
if len(typeFilters) == 0 {
|
|
return full, nil
|
|
}
|
|
|
|
allowed := map[string]bool{}
|
|
for _, t := range typeFilters {
|
|
allowed[t] = true
|
|
}
|
|
|
|
nodeIDs := map[string]bool{}
|
|
var nodes []GraphNode
|
|
for _, n := range full.Nodes {
|
|
if allowed[n.Type] {
|
|
nodes = append(nodes, n)
|
|
nodeIDs[n.ID] = true
|
|
}
|
|
}
|
|
|
|
var edges []GraphEdge
|
|
for _, e := range full.Edges {
|
|
if nodeIDs[e.Source] && nodeIDs[e.Target] {
|
|
edges = append(edges, e)
|
|
}
|
|
}
|
|
|
|
return GraphData{Nodes: nodes, Edges: edges}, nil
|
|
}
|
|
|
|
// --- Search ---
|
|
|
|
func (a *App) SearchEntities(query string) ([]ops.Entity, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[SearchEntities] query=%q", query)
|
|
results, err := searchEntitiesFTS(a.db, query)
|
|
if err != nil {
|
|
log.Printf("[SearchEntities] ERROR: %v", err)
|
|
} else {
|
|
log.Printf("[SearchEntities] found %d results", len(results))
|
|
}
|
|
return results, err
|
|
}
|
|
|
|
func (a *App) SearchGraph(query string) (GraphData, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return GraphData{}, fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[SearchGraph] query=%q", query)
|
|
return searchGraph(a.db, query)
|
|
}
|
|
|
|
// --- Assertions ---
|
|
|
|
func (a *App) ListAssertions(entityID string) ([]ops.Assertion, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[ListAssertions] entityID=%q", entityID)
|
|
active := true
|
|
return a.db.ListAssertions(entityID, &active)
|
|
}
|
|
|
|
func (a *App) AddAssertion(input AssertionInput) (string, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return "", fmt.Errorf("no project selected")
|
|
}
|
|
|
|
id := generateID()
|
|
log.Printf("[AddAssertion] name=%q entityID=%s id=%s", input.Name, input.EntityID, id)
|
|
|
|
assertion := &ops.Assertion{
|
|
ID: id,
|
|
EntityID: input.EntityID,
|
|
Name: input.Name,
|
|
Kind: input.Kind,
|
|
Rule: input.Rule,
|
|
Severity: ops.Severity(input.Severity),
|
|
Description: input.Description,
|
|
Active: true,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := a.db.InsertAssertion(assertion); err != nil {
|
|
log.Printf("[AddAssertion] ERROR: %v", err)
|
|
return "", err
|
|
}
|
|
log.Printf("[AddAssertion] OK: %s", id)
|
|
return id, nil
|
|
}
|
|
|
|
func (a *App) DeleteAssertion(id string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[DeleteAssertion] id=%s", id)
|
|
return a.db.DeleteAssertion(id)
|
|
}
|
|
|
|
func (a *App) EvalAssertions(entityID string) ([]ops.AssertionResult, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
log.Printf("[EvalAssertions] entityID=%s", entityID)
|
|
results, err := ops.EvalEntityAssertions(a.db, entityID, "")
|
|
if err != nil {
|
|
log.Printf("[EvalAssertions] ERROR: %v", err)
|
|
} else {
|
|
log.Printf("[EvalAssertions] %d results", len(results))
|
|
}
|
|
return results, err
|
|
}
|
|
|
|
// --- Presets ---
|
|
|
|
func (a *App) GetEntityPresets() []EntityTypePreset {
|
|
log.Printf("[GetEntityPresets] returning %d presets", len(entityTypePresets))
|
|
return entityTypePresets
|
|
}
|
|
|
|
func (a *App) GetRelationPresets() []string {
|
|
log.Printf("[GetRelationPresets] returning %d presets", len(relationPresets))
|
|
return relationPresets
|
|
}
|
|
|
|
// --- Enrichers ---
|
|
|
|
func (a *App) GetEnrichers() []EnricherDef {
|
|
log.Printf("[GetEnrichers] returning %d enrichers", len(enricherRegistry))
|
|
return enricherRegistry
|
|
}
|
|
|
|
func (a *App) GetEnrichersForEntity(entityID string) ([]EnricherDef, error) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.db == nil {
|
|
return nil, fmt.Errorf("no project selected")
|
|
}
|
|
entity, err := a.db.GetEntity(entityID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if entity == nil {
|
|
return nil, fmt.Errorf("entity %s not found", entityID)
|
|
}
|
|
result := enrichersForType(entity.TypeRef)
|
|
log.Printf("[GetEnrichersForEntity] entityID=%s typeRef=%s -> %d enrichers", entityID, entity.TypeRef, len(result))
|
|
return result, nil
|
|
}
|
|
|
|
func (a *App) RunEnricher(enricherID, entityID string) (GraphData, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return GraphData{}, fmt.Errorf("no project selected")
|
|
}
|
|
|
|
log.Printf("[RunEnricher] enricherID=%s entityID=%s", enricherID, entityID)
|
|
|
|
enricher := findEnricher(enricherID)
|
|
if enricher == nil {
|
|
return GraphData{}, fmt.Errorf("enricher %s not found", enricherID)
|
|
}
|
|
|
|
entity, err := a.db.GetEntity(entityID)
|
|
if err != nil {
|
|
return GraphData{}, err
|
|
}
|
|
if entity == nil {
|
|
return GraphData{}, fmt.Errorf("entity %s not found", entityID)
|
|
}
|
|
|
|
// Serialize entity to JSON for the Python script
|
|
entityJSON, err := json.Marshal(map[string]any{
|
|
"id": entity.ID,
|
|
"name": entity.Name,
|
|
"type_ref": entity.TypeRef,
|
|
"metadata": entity.Metadata,
|
|
})
|
|
if err != nil {
|
|
return GraphData{}, fmt.Errorf("marshaling entity: %w", err)
|
|
}
|
|
|
|
// Run enricher
|
|
enrichersDir := filepath.Join(filepath.Dir(os.Args[0]), "enrichers")
|
|
// Fallback: try relative to working directory
|
|
if _, err := os.Stat(enrichersDir); err != nil {
|
|
enrichersDir = "enrichers"
|
|
}
|
|
// Fallback: try relative to project dir
|
|
if _, err := os.Stat(filepath.Join(enrichersDir, enricher.Script)); err != nil {
|
|
// Try from the app source directory
|
|
if exePath, err2 := os.Executable(); err2 == nil {
|
|
enrichersDir = filepath.Join(filepath.Dir(exePath), "enrichers")
|
|
}
|
|
}
|
|
|
|
log.Printf("[RunEnricher] executing %s in %s", enricher.Script, enrichersDir)
|
|
result, err := runEnricherScript(a.registryRoot, enrichersDir, enricher.Script, entityJSON)
|
|
if err != nil {
|
|
log.Printf("[RunEnricher] ERROR: %v", err)
|
|
return GraphData{}, err
|
|
}
|
|
|
|
log.Printf("[RunEnricher] result: %d entities, %d relations", len(result.Entities), len(result.Relations))
|
|
|
|
// Insert results into operations.db
|
|
if err := a.insertEnricherResults(result, entityID); err != nil {
|
|
log.Printf("[RunEnricher] ERROR inserting results: %v", err)
|
|
return GraphData{}, err
|
|
}
|
|
|
|
// Return full graph
|
|
data, err := buildGraphData(a.db)
|
|
if err != nil {
|
|
return GraphData{}, err
|
|
}
|
|
log.Printf("[RunEnricher] OK: graph now has %d nodes, %d edges", len(data.Nodes), len(data.Edges))
|
|
return data, nil
|
|
}
|
|
|
|
// --- Ingest ---
|
|
|
|
func (a *App) IngestURL(url string) (string, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return "", fmt.Errorf("no project selected")
|
|
}
|
|
|
|
url = strings.TrimSpace(url)
|
|
if url == "" {
|
|
return "", fmt.Errorf("URL cannot be empty")
|
|
}
|
|
|
|
log.Printf("[IngestURL] url=%s", url)
|
|
|
|
id := makeEntityID(url, "url")
|
|
now := time.Now()
|
|
e := &ops.Entity{
|
|
ID: id,
|
|
Name: url,
|
|
TypeRef: "url",
|
|
Status: ops.StatusActive,
|
|
Description: "Ingested URL",
|
|
Domain: "fuzzygraph",
|
|
Tags: []string{"ingested"},
|
|
Source: "fuzzygraph",
|
|
Metadata: map[string]any{"url": url},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if a.registryDB != nil {
|
|
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
|
|
if err2 := a.db.InsertEntity(e); err2 != nil {
|
|
return "", err2
|
|
}
|
|
}
|
|
} else {
|
|
if err := a.db.InsertEntity(e); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
log.Printf("[IngestURL] OK: %s", id)
|
|
return id, nil
|
|
}
|
|
|
|
func (a *App) IngestFile(filePath string) (string, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.db == nil {
|
|
return "", fmt.Errorf("no project selected")
|
|
}
|
|
|
|
filePath = strings.TrimSpace(filePath)
|
|
if filePath == "" {
|
|
return "", fmt.Errorf("file path cannot be empty")
|
|
}
|
|
|
|
log.Printf("[IngestFile] path=%s", filePath)
|
|
|
|
name := filepath.Base(filePath)
|
|
ext := strings.TrimPrefix(filepath.Ext(filePath), ".")
|
|
id := makeEntityID(name, "document")
|
|
now := time.Now()
|
|
|
|
e := &ops.Entity{
|
|
ID: id,
|
|
Name: name,
|
|
TypeRef: "document",
|
|
Status: ops.StatusActive,
|
|
Description: fmt.Sprintf("Ingested document (%s)", ext),
|
|
Domain: "fuzzygraph",
|
|
Tags: []string{"ingested"},
|
|
Source: "fuzzygraph",
|
|
Metadata: map[string]any{"file_path": filePath, "format": ext},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if a.registryDB != nil {
|
|
if err := ops.InsertEntityWithSnapshot(a.db, a.registryDB, e); err != nil {
|
|
if err2 := a.db.InsertEntity(e); err2 != nil {
|
|
return "", err2
|
|
}
|
|
}
|
|
} else {
|
|
if err := a.db.InsertEntity(e); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
log.Printf("[IngestFile] OK: %s", id)
|
|
return id, nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func makeEntityID(name, typeRef string) string {
|
|
clean := strings.ToLower(strings.TrimSpace(name))
|
|
clean = strings.ReplaceAll(clean, " ", "_")
|
|
clean = strings.ReplaceAll(clean, "-", "_")
|
|
// Truncate long names (URLs etc.)
|
|
if len(clean) > 60 {
|
|
clean = clean[:60]
|
|
}
|
|
return fmt.Sprintf("%s_%s", clean, typeRef)
|
|
}
|
|
|
|
func generateID() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|