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]) }