From d180356684d843ff5a2fa4bbdc130efa9d9048b2 Mon Sep 17 00:00:00 2001 From: Matt Artz Date: Thu, 2 Jul 2026 14:23:13 +0200 Subject: [PATCH 1/6] Add Gephi MCP plugin 1.1.3 HTTP API plugin enabling AI assistants to control Gephi via the Model Context Protocol. Part of gephi-ai: https://github.com/MattArtzAnthro/gephi-ai --- modules/GephiMcp/pom.xml | 134 + .../java/org/gephi/plugins/mcp/Installer.java | 97 + .../gephi/plugins/mcp/api/GephiAPIServer.java | 630 +++++ .../mcp/service/GephiControlService.java | 2395 +++++++++++++++++ modules/GephiMcp/src/main/nbm/manifest.mf | 3 + .../org/gephi/plugins/mcp/Bundle.properties | 4 + .../gephi/plugins/mcp/api/HostHeaderTest.java | 39 + .../plugins/mcp/service/GraphOpsTest.java | 190 ++ .../plugins/mcp/service/HelpersTest.java | 128 + pom.xml | 1 + 10 files changed, 3621 insertions(+) create mode 100644 modules/GephiMcp/pom.xml create mode 100644 modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java create mode 100644 modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java create mode 100644 modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java create mode 100644 modules/GephiMcp/src/main/nbm/manifest.mf create mode 100644 modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties create mode 100644 modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java create mode 100644 modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java create mode 100644 modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml new file mode 100644 index 000000000..5502a986b --- /dev/null +++ b/modules/GephiMcp/pom.xml @@ -0,0 +1,134 @@ + + + 4.0.0 + + + org.gephi + gephi-plugin-parent + 0.11.1 + + + org.gephi.plugins + gephi-mcp + 1.1.3 + nbm + + Gephi MCP Plugin + HTTP API for remote Gephi control via MCP (Model Context Protocol) + https://github.com/MattArtzAnthro/gephi-ai + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Matt Artz + https://www.mattartz.me + + + + + + + org.gephi + graph-api + + + org.gephi + project-api + + + org.gephi + layout-api + + + org.gephi + io-exporter-api + + + org.gephi + io-importer-api + + + org.gephi + statistics-api + + + org.gephi + appearance-api + + + org.gephi + preview-api + + + org.gephi + filters-api + + + + + org.netbeans.api + org-openide-util + + + org.netbeans.api + org-openide-util-lookup + + + org.netbeans.api + org-openide-modules + + + org.netbeans.api + org-openide-nodes + + + + + org.nanohttpd + nanohttpd + 2.3.1 + + + com.google.code.gson + gson + 2.10.1 + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + Apache 2.0 + Matt Artz + https://www.mattartz.me + https://github.com/MattArtzAnthro/gephi-ai + https://github.com/MattArtzAnthro/gephi-ai + + org.gephi.plugins.mcp.api + + + + + + diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java new file mode 100644 index 000000000..ade7022e1 --- /dev/null +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java @@ -0,0 +1,97 @@ +package org.gephi.plugins.mcp; + +import java.util.logging.Level; +import java.util.logging.Logger; +import org.gephi.plugins.mcp.api.GephiAPIServer; +import org.openide.modules.ModuleInstall; + +public class Installer extends ModuleInstall { + + private static final Logger LOGGER = Logger.getLogger(Installer.class.getName()); + private static final int DEFAULT_PORT = 8080; + private GephiAPIServer server; + + @Override + public void restored() { + LOGGER.info("########## Gephi MCP Plugin: restored() called ##########"); + System.out.println("########## Gephi MCP Plugin: restored() called ##########"); + + // Start server in a new thread to not block module loading + Thread serverThread = new Thread(() -> { + try { + Thread.sleep(2000); // Wait for Gephi to fully initialize + startServer(); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to start MCP server", e); + System.err.println("Failed to start MCP server: " + e.getMessage()); + e.printStackTrace(); + } + }, "MCP-Server-Starter"); + serverThread.setDaemon(true); + serverThread.start(); + } + + @Override + public void close() { + LOGGER.info("########## Gephi MCP Plugin: close() called ##########"); + stopServer(); + } + + @Override + public boolean closing() { + LOGGER.info("########## Gephi MCP Plugin: closing() called ##########"); + stopServer(); + return true; + } + + private void startServer() { + try { + int port = Integer.getInteger("gephi.mcp.port", DEFAULT_PORT); + LOGGER.info("########## Starting MCP server on port " + port + " ##########"); + System.out.println("########## Starting MCP server on port " + port + " ##########"); + + server = new GephiAPIServer(port); + server.startServer(); + + LOGGER.info("==========================================="); + LOGGER.info(" Gephi MCP Plugin Active"); + LOGGER.info(" API: http://127.0.0.1:" + port); + LOGGER.info("==========================================="); + System.out.println("==========================================="); + System.out.println(" Gephi MCP Plugin Active"); + System.out.println(" API: http://127.0.0.1:" + port); + System.out.println("==========================================="); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to start MCP server", e); + System.err.println("Failed to start MCP server: " + e.getMessage()); + e.printStackTrace(); + } + } + + private void stopServer() { + final GephiAPIServer s = server; + server = null; + if (s == null) return; + // Stop on a daemon watchdog so a slow/stuck socket close can never block + // Gephi's shutdown (which runs on the EDT). Wait at most 3s, then continue + // regardless; the daemon threads can't keep the JVM alive. + Thread stopper = new Thread(() -> { + try { + s.stopServer(); + LOGGER.info("########## MCP server stopped ##########"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error stopping server", e); + } + }, "MCP-Server-Stopper"); + stopper.setDaemon(true); + stopper.start(); + try { + stopper.join(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (stopper.isAlive()) { + LOGGER.warning("MCP server stop exceeded 3s; continuing Gephi shutdown"); + } + } +} diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java new file mode 100644 index 000000000..b7fa65646 --- /dev/null +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -0,0 +1,630 @@ +package org.gephi.plugins.mcp.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import fi.iki.elonen.NanoHTTPD; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.gephi.plugins.mcp.service.GephiControlService; + +public class GephiAPIServer extends NanoHTTPD { + + private static final Logger LOGGER = Logger.getLogger(GephiAPIServer.class.getName()); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private final GephiControlService service; + + public GephiAPIServer(int port) { + super("127.0.0.1", port); + this.service = GephiControlService.getInstance(); + } + + @Override + public Response serve(IHTTPSession session) { + String uri = session.getUri(); + Method method = session.getMethod(); + + LOGGER.info("MCP API: " + method + " " + uri); + + // Defense against DNS-rebinding: the API is reachable only from local + // processes (the MCP server is a local Python process, not a browser). + // Reject any request whose Host header points at a non-local name, which + // is how a malicious web page would try to reach 127.0.0.1 via a rebound + // hostname. Requests with no Host header (e.g. raw curl) are allowed. + if (!isLocalHost(session)) { + JsonObject error = new JsonObject(); + error.addProperty("success", false); + error.addProperty("error", "Forbidden: requests must target localhost"); + return newFixedLengthResponse(Response.Status.FORBIDDEN, "application/json", GSON.toJson(error)); + } + + if (Method.OPTIONS.equals(method)) { + return newFixedLengthResponse(Response.Status.OK, "text/plain", ""); + } + + try { + JsonObject requestBody = null; + if (Method.POST.equals(method) || Method.PUT.equals(method)) { + Map files = new HashMap<>(); + session.parseBody(files); + String body = files.get("postData"); + if (body != null && !body.isEmpty()) { + requestBody = JsonParser.parseString(body).getAsJsonObject(); + } + } + + JsonObject result = routeRequest(uri, method, session.getParms(), requestBody); + + Response.Status status = result.has("success") && result.get("success").getAsBoolean() + ? Response.Status.OK : Response.Status.BAD_REQUEST; + + return newFixedLengthResponse(status, "application/json", GSON.toJson(result)); + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "API error", e); + JsonObject error = new JsonObject(); + error.addProperty("success", false); + error.addProperty("error", e.getMessage()); + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "application/json", GSON.toJson(error)); + } + } + + /** True when the request's Host header is absent or resolves to the loopback interface. */ + private boolean isLocalHost(IHTTPSession session) { + return isLoopbackHost(session.getHeaders().get("host")); + } + + /** + * True when {@code host} (a raw HTTP Host header value, possibly null or with a + * port and/or IPv6 brackets) names the loopback interface. A null/empty header is + * allowed (non-browser clients like the MCP server may omit it); any non-loopback + * name is rejected, which is what blocks DNS-rebinding attacks from a web page. + * Package-private and static so it can be unit-tested without a live server. + */ + static boolean isLoopbackHost(String host) { + if (host == null || host.isEmpty()) return true; + String name = host; + int colon = name.lastIndexOf(':'); + if (colon > -1 && name.indexOf(']') < colon) name = name.substring(0, colon); + name = name.replace("[", "").replace("]", "").trim().toLowerCase(); + return name.equals("127.0.0.1") || name.equals("localhost") || name.equals("::1"); + } + + @SuppressWarnings("unchecked") + private JsonObject routeRequest(String uri, Method method, Map params, JsonObject body) { + + // ─── Health ────────────────────────────────────────────────── + + if ("/health".equals(uri) || "/".equals(uri)) { + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.addProperty("service", "Gephi MCP API"); + result.addProperty("version", "1.1.3"); + result.addProperty("status", "running"); + return result; + } + + // ─── Project ───────────────────────────────────────────────── + + if ("/project/new".equals(uri) && Method.POST.equals(method)) { + String name = body != null && body.has("name") ? body.get("name").getAsString() : "New Project"; + return service.createProject(name); + } + + if ("/project/open".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file' parameter"); + return service.openProject(body.get("file").getAsString()); + } + + if ("/project/save".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file' parameter"); + return service.saveProject(body.get("file").getAsString()); + } + + if ("/project/info".equals(uri) && Method.GET.equals(method)) { + return service.getProjectInfo(); + } + + // ─── Workspace ─────────────────────────────────────────────── + + if ("/workspace/new".equals(uri) && Method.POST.equals(method)) { + return service.newWorkspace(); + } + + if ("/workspace/list".equals(uri) && Method.GET.equals(method)) { + return service.listWorkspaces(); + } + + if ("/workspace/switch".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("index")) return errorResult("Missing 'index'"); + return service.switchWorkspace(body.get("index").getAsInt()); + } + + if ("/workspace/delete".equals(uri) && Method.DELETE.equals(method)) { + int index = params.containsKey("index") ? Integer.parseInt(params.get("index")) : -1; + if (index < 0 && body != null && body.has("index")) index = body.get("index").getAsInt(); + if (index < 0) return errorResult("Missing 'index'"); + return service.deleteWorkspace(index); + } + + if ("/workspace/duplicate".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("index")) return errorResult("Missing 'index'"); + return service.duplicateWorkspace(body.get("index").getAsInt()); + } + + if ("/workspace/rename".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("index") || !body.has("name")) return errorResult("Missing 'index' or 'name'"); + return service.renameWorkspace(body.get("index").getAsInt(), body.get("name").getAsString()); + } + + // ─── Nodes ─────────────────────────────────────────────────── + + if ("/graph/node/add".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("id")) return errorResult("Missing 'id' parameter"); + String id = body.get("id").getAsString(); + String label = body.has("label") ? body.get("label").getAsString() : null; + Map attrs = null; + if (body.has("attributes") && body.get("attributes").isJsonObject()) { + attrs = GSON.fromJson(body.get("attributes"), Map.class); + } + return service.addNode(id, label, attrs); + } + + if ("/graph/nodes/add".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("nodes")) return errorResult("Missing 'nodes' array"); + List> nodes = GSON.fromJson(body.get("nodes"), List.class); + return service.addNodes(nodes); + } + + if (uri.startsWith("/graph/node/") && uri.length() > "/graph/node/".length() && Method.DELETE.equals(method)) { + String nodeId = uri.substring("/graph/node/".length()); + if (nodeId.isEmpty()) return errorResult("Missing node ID"); + return service.removeNode(nodeId); + } + + if ("/graph/nodes/remove".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("ids")) return errorResult("Missing 'ids' array"); + List ids = GSON.fromJson(body.get("ids"), List.class); + return service.bulkRemoveNodes(ids); + } + + if ("/graph/nodes".equals(uri) && Method.GET.equals(method)) { + int limit = parseIntParam(params.get("limit"), 100); + int offset = parseIntParam(params.get("offset"), 0); + return service.queryNodes(null, null, limit, offset); + } + + if (uri.startsWith("/graph/node/get/") && Method.GET.equals(method)) { + String nodeId = uri.substring("/graph/node/get/".length()); + if (nodeId.isEmpty()) return errorResult("Missing node ID"); + return service.getNode(nodeId); + } + + if ("/graph/node/label".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("id") || !body.has("label")) return errorResult("Missing 'id' or 'label'"); + return service.setNodeLabel(body.get("id").getAsString(), body.get("label").getAsString()); + } + + if ("/graph/node/position".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("id")) return errorResult("Missing 'id'"); + float x = body.has("x") ? body.get("x").getAsFloat() : 0; + float y = body.has("y") ? body.get("y").getAsFloat() : 0; + return service.setNodePosition(body.get("id").getAsString(), x, y); + } + + if ("/graph/nodes/positions".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("positions")) return errorResult("Missing 'positions' array"); + List> positions = GSON.fromJson(body.get("positions"), List.class); + return service.batchSetPositions(positions); + } + + // ─── Edges ─────────────────────────────────────────────────── + + if ("/graph/edge/add".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) + return errorResult("Missing 'source' or 'target'"); + String source = body.get("source").getAsString(); + String target = body.get("target").getAsString(); + Double weight = body.has("weight") ? body.get("weight").getAsDouble() : 1.0; + boolean directed = !body.has("directed") || body.get("directed").getAsBoolean(); + return service.addEdge(source, target, weight, directed); + } + + if ("/graph/edges/add".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("edges")) return errorResult("Missing 'edges' array"); + List> edges = GSON.fromJson(body.get("edges"), List.class); + return service.addEdges(edges); + } + + if ("/graph/edge/remove".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) + return errorResult("Missing 'source' or 'target'"); + return service.removeEdge(body.get("source").getAsString(), body.get("target").getAsString()); + } + + if ("/graph/edge/weight".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("source") || !body.has("target") || !body.has("weight")) + return errorResult("Missing 'source', 'target', or 'weight'"); + return service.setEdgeWeight( + body.get("source").getAsString(), + body.get("target").getAsString(), + body.get("weight").getAsDouble() + ); + } + + if ("/graph/edge/label".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("source") || !body.has("target") || !body.has("label")) + return errorResult("Missing 'source', 'target', or 'label'"); + return service.setEdgeLabel( + body.get("source").getAsString(), + body.get("target").getAsString(), + body.get("label").getAsString() + ); + } + + if ("/graph/edges".equals(uri) && Method.GET.equals(method)) { + int limit = parseIntParam(params.get("limit"), 100); + int offset = parseIntParam(params.get("offset"), 0); + return service.queryEdges(limit, offset); + } + + // ─── Graph Stats & Type ────────────────────────────────────── + + if ("/graph/stats".equals(uri) && Method.GET.equals(method)) { + return service.getGraphStats(); + } + + if ("/graph/type".equals(uri) && Method.GET.equals(method)) { + return service.getGraphType(); + } + + // ─── Attributes / Columns ──────────────────────────────────── + + if ("/graph/columns".equals(uri) && Method.GET.equals(method)) { + String target = params.getOrDefault("target", "node"); + return service.getColumns(target); + } + + if ("/graph/columns/add".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("name") || !body.has("type")) + return errorResult("Missing 'name' or 'type'"); + String target = body.has("target") ? body.get("target").getAsString() : "node"; + return service.addColumn(body.get("name").getAsString(), body.get("type").getAsString(), target); + } + + if ("/graph/node/attributes".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("id") || !body.has("attributes")) + return errorResult("Missing 'id' or 'attributes'"); + Map attrs = GSON.fromJson(body.get("attributes"), Map.class); + return service.setNodeAttributes(body.get("id").getAsString(), attrs); + } + + if ("/graph/nodes/attributes".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("updates")) return errorResult("Missing 'updates' array"); + List> updates = GSON.fromJson(body.get("updates"), List.class); + return service.batchSetNodeAttributes(updates); + } + + if ("/graph/edge/attributes".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("source") || !body.has("target") || !body.has("attributes")) + return errorResult("Missing 'source', 'target', or 'attributes'"); + Map attrs = GSON.fromJson(body.get("attributes"), Map.class); + return service.setEdgeAttributes( + body.get("source").getAsString(), + body.get("target").getAsString(), + attrs + ); + } + + // ─── Appearance ────────────────────────────────────────────── + + if ("/appearance/node/color".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("id")) return errorResult("Missing 'id'"); + int r = body.has("r") ? body.get("r").getAsInt() : 0; + int g = body.has("g") ? body.get("g").getAsInt() : 0; + int b = body.has("b") ? body.get("b").getAsInt() : 0; + int a = body.has("a") ? body.get("a").getAsInt() : 255; + return service.setNodeColor(body.get("id").getAsString(), r, g, b, a); + } + + if ("/appearance/node/size".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("id") || !body.has("size")) return errorResult("Missing 'id' or 'size'"); + return service.setNodeSize(body.get("id").getAsString(), body.get("size").getAsFloat()); + } + + if ("/appearance/edge/color".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) + return errorResult("Missing 'source' or 'target'"); + int r = body.has("r") ? body.get("r").getAsInt() : 0; + int g = body.has("g") ? body.get("g").getAsInt() : 0; + int b = body.has("b") ? body.get("b").getAsInt() : 0; + int a = body.has("a") ? body.get("a").getAsInt() : 255; + return service.setEdgeColor(body.get("source").getAsString(), body.get("target").getAsString(), r, g, b, a); + } + + if ("/appearance/nodes/color".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("nodes")) return errorResult("Missing 'nodes' array"); + List> nodes = GSON.fromJson(body.get("nodes"), List.class); + return service.batchSetNodeColors(nodes); + } + + if ("/appearance/reset".equals(uri) && Method.POST.equals(method)) { + int r = body != null && body.has("r") ? body.get("r").getAsInt() : 153; + int g = body != null && body.has("g") ? body.get("g").getAsInt() : 153; + int b = body != null && body.has("b") ? body.get("b").getAsInt() : 153; + float size = body != null && body.has("size") ? body.get("size").getAsFloat() : 10f; + return service.resetAppearance(r, g, b, size); + } + + if ("/appearance/partition/color".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column")) return errorResult("Missing 'column'"); + String column = body.get("column").getAsString(); + Map colorMap = null; + if (body.has("colors") && body.get("colors").isJsonObject()) { + colorMap = new HashMap<>(); + JsonObject colors = body.getAsJsonObject("colors"); + for (String key : colors.keySet()) { + List rgb = GSON.fromJson(colors.get(key), List.class); + colorMap.put(key, new int[]{rgb.get(0).intValue(), rgb.get(1).intValue(), rgb.get(2).intValue()}); + } + } + return service.colorByPartition(column, colorMap); + } + + if ("/appearance/ranking/color".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column")) return errorResult("Missing 'column'"); + String column = body.get("column").getAsString(); + int rMin = body.has("r_min") ? body.get("r_min").getAsInt() : 255; + int gMin = body.has("g_min") ? body.get("g_min").getAsInt() : 255; + int bMin = body.has("b_min") ? body.get("b_min").getAsInt() : 200; + int rMax = body.has("r_max") ? body.get("r_max").getAsInt() : 255; + int gMax = body.has("g_max") ? body.get("g_max").getAsInt() : 0; + int bMax = body.has("b_max") ? body.get("b_max").getAsInt() : 0; + return service.colorByRanking(column, rMin, gMin, bMin, rMax, gMax, bMax); + } + + if ("/appearance/ranking/size".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column")) return errorResult("Missing 'column'"); + float minSize = body.has("min_size") ? body.get("min_size").getAsFloat() : 5f; + float maxSize = body.has("max_size") ? body.get("max_size").getAsFloat() : 50f; + return service.sizeByRanking(body.get("column").getAsString(), minSize, maxSize); + } + + // ─── Layout ────────────────────────────────────────────────── + + if ("/layout/run".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("algorithm")) return errorResult("Missing 'algorithm'"); + String algo = body.get("algorithm").getAsString(); + int iterations = body.has("iterations") ? body.get("iterations").getAsInt() : 1000; + // Check if properties are provided - use setLayoutProperties for that + if (body.has("properties") && body.get("properties").isJsonObject()) { + Map properties = GSON.fromJson(body.get("properties"), Map.class); + return service.setLayoutProperties(algo, properties, iterations); + } + return service.runLayout(algo, iterations); + } + + if ("/layout/stop".equals(uri) && Method.POST.equals(method)) { + return service.stopLayout(); + } + + if ("/layout/status".equals(uri) && Method.GET.equals(method)) { + return service.getLayoutStatus(); + } + + if ("/layout/available".equals(uri) && Method.GET.equals(method)) { + return service.getAvailableLayouts(); + } + + if ("/layout/properties".equals(uri) && Method.GET.equals(method)) { + String algo = params.get("algorithm"); + if (algo == null || algo.isEmpty()) return errorResult("Missing 'algorithm' parameter"); + return service.getLayoutProperties(algo); + } + + if ("/layout/properties".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("algorithm") || !body.has("properties")) + return errorResult("Missing 'algorithm' or 'properties'"); + String algo = body.get("algorithm").getAsString(); + Map properties = GSON.fromJson(body.get("properties"), Map.class); + int iterations = body.has("iterations") ? body.get("iterations").getAsInt() : 1000; + return service.setLayoutProperties(algo, properties, iterations); + } + + // ─── Statistics ────────────────────────────────────────────── + + if ("/statistics/modularity".equals(uri) && Method.POST.equals(method)) { + double res = body != null && body.has("resolution") ? body.get("resolution").getAsDouble() : 1.0; + return service.computeModularity(res); + } + + if ("/statistics/degree".equals(uri) && Method.POST.equals(method)) { + return service.computeDegree(); + } + + if ("/statistics/betweenness".equals(uri) && Method.POST.equals(method)) { + return service.computeBetweenness(); + } + + if ("/statistics/pagerank".equals(uri) && Method.POST.equals(method)) { + return service.computePageRank(); + } + + if ("/statistics/connected-components".equals(uri) && Method.POST.equals(method)) { + return service.computeConnectedComponents(); + } + + if ("/statistics/clustering-coefficient".equals(uri) && Method.POST.equals(method)) { + return service.computeClusteringCoefficient(); + } + + if ("/statistics/avg-path-length".equals(uri) && Method.POST.equals(method)) { + return service.computeAvgPathLength(); + } + + if ("/statistics/hits".equals(uri) && Method.POST.equals(method)) { + return service.computeHITS(); + } + + if ("/statistics/eigenvector".equals(uri) && Method.POST.equals(method)) { + return service.computeEigenvectorCentrality(); + } + + // ─── Graph Operations ──────────────────────────────────────── + + if ("/graph/clear".equals(uri) && Method.POST.equals(method)) { + return service.clearGraph(); + } + + // ─── Filters ───────────────────────────────────────────────── + + if ("/filter/degree".equals(uri) && Method.POST.equals(method)) { + int min = body != null && body.has("min") ? body.get("min").getAsInt() : 0; + int max = body != null && body.has("max") ? body.get("max").getAsInt() : 0; + boolean dryRun = body != null && body.has("dry_run") && body.get("dry_run").getAsBoolean(); + return service.filterByDegreeRange(min, max, dryRun); + } + + if ("/filter/edge-weight".equals(uri) && Method.POST.equals(method)) { + double min = body != null && body.has("min") ? body.get("min").getAsDouble() : 0; + double max = body != null && body.has("max") ? body.get("max").getAsDouble() : 0; + boolean dryRun = body != null && body.has("dry_run") && body.get("dry_run").getAsBoolean(); + return service.filterByEdgeWeight(min, max, dryRun); + } + + if ("/filter/remove-isolates".equals(uri) && Method.POST.equals(method)) { + return service.removeIsolates(); + } + + if ("/filter/ego-network".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("node_id")) return errorResult("Missing 'node_id'"); + String nodeId = body.get("node_id").getAsString(); + int depth = body.has("depth") ? body.get("depth").getAsInt() : 1; + return service.extractEgoNetwork(nodeId, depth); + } + + if ("/filter/giant-component".equals(uri) && Method.POST.equals(method)) { + return service.extractGiantComponent(); + } + + if ("/filter/reset".equals(uri) && Method.POST.equals(method)) { + return service.resetFilters(); + } + + // ─── Edge Appearance ──────────────────────────────────────── + + if ("/appearance/edge/thickness-by-weight".equals(uri) && Method.POST.equals(method)) { + float minThickness = body != null && body.has("min_thickness") ? body.get("min_thickness").getAsFloat() : 1f; + float maxThickness = body != null && body.has("max_thickness") ? body.get("max_thickness").getAsFloat() : 5f; + return service.setEdgeThicknessByWeight(minThickness, maxThickness); + } + + // ─── Preview ───────────────────────────────────────────────── + + if ("/preview/settings".equals(uri) && Method.GET.equals(method)) { + return service.getPreviewSettings(); + } + + if ("/preview/settings".equals(uri) && Method.POST.equals(method)) { + if (body == null) return errorResult("Missing request body"); + Map settings = GSON.fromJson(body, Map.class); + return service.setPreviewSettings(settings); + } + + // ─── Export ────────────────────────────────────────────────── + + if ("/export/gexf".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.exportGexf(body.get("file").getAsString()); + } + + if ("/export/png".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + String file = body.get("file").getAsString(); + int w = body.has("width") ? body.get("width").getAsInt() : 1920; + int h = body.has("height") ? body.get("height").getAsInt() : 1080; + return service.exportPng(file, w, h); + } + + if ("/export/pdf".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + String file = body.get("file").getAsString(); + int w = body.has("width") ? body.get("width").getAsInt() : 0; + int h = body.has("height") ? body.get("height").getAsInt() : 0; + return service.exportPdf(file, w, h); + } + + if ("/export/svg".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.exportSvg(body.get("file").getAsString()); + } + + if ("/export/graphml".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.exportGraphml(body.get("file").getAsString()); + } + + if ("/export/csv".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + String file = body.get("file").getAsString(); + String separator = body.has("separator") ? body.get("separator").getAsString() : ","; + String target = body.has("target") ? body.get("target").getAsString() : "nodes"; + return service.exportCsv(file, separator, target); + } + + // ─── Import ────────────────────────────────────────────────── + + if ("/import/gexf".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.importFile(body.get("file").getAsString()); + } + + if ("/import/graphml".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.importFile(body.get("file").getAsString()); + } + + if ("/import/csv".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.importFile(body.get("file").getAsString()); + } + + if ("/import/file".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + return service.importFile(body.get("file").getAsString()); + } + + return errorResult("Unknown endpoint: " + method + " " + uri); + } + + private int parseIntParam(String value, int defaultValue) { + if (value == null) return defaultValue; + try { return Integer.parseInt(value); } + catch (NumberFormatException e) { return defaultValue; } + } + + private JsonObject errorResult(String message) { + JsonObject result = new JsonObject(); + result.addProperty("success", false); + result.addProperty("error", message); + return result; + } + + public void startServer() throws IOException { + // daemon=true: the listener thread must never keep the JVM alive on Gephi + // shutdown. With a non-daemon listener, a missed/slow stop() would hang close. + start(NanoHTTPD.SOCKET_READ_TIMEOUT, true); + LOGGER.info("Gephi MCP API started on http://127.0.0.1:" + getListeningPort()); + } + + public void stopServer() { + stop(); + service.shutdown(); + LOGGER.info("Gephi MCP API stopped"); + } +} diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java new file mode 100644 index 000000000..2b594f3d9 --- /dev/null +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -0,0 +1,2395 @@ +package org.gephi.plugins.mcp.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.StringWriter; +import javax.imageio.ImageIO; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.SwingUtilities; +import org.gephi.appearance.api.AppearanceController; +import org.gephi.appearance.api.AppearanceModel; +import org.gephi.appearance.api.Function; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.DirectedGraph; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphController; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.gephi.graph.api.Table; +import org.gephi.io.exporter.api.ExportController; +import org.gephi.io.exporter.spi.CharacterExporter; +import org.gephi.io.exporter.spi.Exporter; +import org.gephi.io.exporter.spi.GraphExporter; +import org.gephi.io.importer.api.Container; +import org.gephi.io.importer.api.ImportController; +import org.gephi.io.processor.spi.Processor; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutBuilder; +import org.gephi.layout.spi.LayoutProperty; +import org.gephi.preview.api.PreviewController; +import org.gephi.preview.api.PreviewModel; +import org.gephi.preview.api.PreviewProperty; +import org.gephi.preview.types.DependantColor; +import org.gephi.preview.types.DependantOriginalColor; +import org.gephi.preview.types.EdgeColor; +import org.gephi.project.api.ProjectController; +import org.gephi.project.api.Workspace; +import org.gephi.statistics.spi.Statistics; +import org.gephi.statistics.spi.StatisticsBuilder; +import org.openide.util.Lookup; + +public class GephiControlService { + + private static final Logger LOGGER = Logger.getLogger(GephiControlService.class.getName()); + private static GephiControlService instance; + + private final AtomicBoolean layoutRunning = new AtomicBoolean(false); + private volatile String currentLayoutName = null; + private volatile Future layoutFuture = null; + private final ExecutorService layoutExecutor = Executors.newSingleThreadExecutor(); + // Stored when setPreviewSettings receives background.color — used by exportPng to composite background + private volatile Color exportBackgroundColor = null; + + private GephiControlService() {} + + public static synchronized GephiControlService getInstance() { + if (instance == null) instance = new GephiControlService(); + return instance; + } + + // ─── Helpers ───────────────────────────────────────────────────── + + private ProjectController getProjectController() { + return Lookup.getDefault().lookup(ProjectController.class); + } + + private GraphController getGraphController() { + return Lookup.getDefault().lookup(GraphController.class); + } + + @SuppressWarnings("unchecked") + private T runOnEDT(Callable callable) { + if (SwingUtilities.isEventDispatchThread()) { + try { return callable.call(); } + catch (Exception e) { throw new RuntimeException(e); } + } + final Object[] result = new Object[1]; + final Exception[] exception = new Exception[1]; + try { + SwingUtilities.invokeAndWait(() -> { + try { result[0] = callable.call(); } + catch (Exception e) { exception[0] = e; } + }); + } catch (Exception e) { throw new RuntimeException(e); } + if (exception[0] != null) throw new RuntimeException(exception[0]); + return (T) result[0]; + } + + static JsonObject success(String msg) { + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("message", msg); + return r; + } + + static JsonObject error(String msg) { + JsonObject r = new JsonObject(); + r.addProperty("success", false); + r.addProperty("error", msg); + return r; + } + + private Workspace currentWorkspace() { + return getProjectController().getCurrentWorkspace(); + } + + private GraphModel currentGraphModel() { + Workspace ws = currentWorkspace(); + return ws != null ? getGraphController().getGraphModel(ws) : null; + } + + // ─── Write-lock acquisition (VizEngine-deadlock-safe) ──────────────── + + private static volatile java.lang.reflect.Field WRITE_LOCK_FIELD; + + /** + * Acquire the graph write lock by polling a non-queuing tryLock() instead of the + * blocking writeLock(). Gephi's OpenGL VizEngine runs a concurrent "world updater" + * that holds read locks while join()-ing on sub-tasks that also need read locks; a + * writer parked indefinitely in the lock's wait queue blocks those sub-readers (writer + * preference) and deadlocks the renderer permanently (the chronic macOS hang). + * + * We instead use a SHORT timed tryLock: it queues only briefly, so it still gets + * writer-preference and acquires even while the renderer reads near-continuously + * (e.g. right after a layout) — but if it lands in the nested-read window it times out, + * dequeues, lets the renderer drain, and retries. So it can never wedge. Once we hold + * the lock, any Gephi-internal writeLock() on this same thread (setVisibleView, etc.) + * re-enters for free, which is why callers wrap those calls too. Falls back to the plain + * blocking lock only if the underlying lock can't be reflected. Throws after ~15s, which + * callers turn into a "graph busy" error instead of hanging forever. + */ + static void lockWrite(Graph g) { + java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock wl = writeLockHandle(g); + if (wl == null) { g.writeLock(); return; } + long deadline = System.nanoTime() + 15_000_000_000L; + try { + while (!wl.tryLock(120, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (System.nanoTime() > deadline) + throw new RuntimeException("Graph is busy (renderer holds the lock); please retry"); + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while acquiring the write lock"); + } + } + + /** The underlying ReentrantReadWriteLock.WriteLock behind Graph.getLock(), or null if unreachable. */ + static java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock writeLockHandle(Graph g) { + try { + org.gephi.graph.api.GraphLock lock = g.getLock(); + if (lock == null) return null; + java.lang.reflect.Field f = WRITE_LOCK_FIELD; + if (f == null || !f.getDeclaringClass().isInstance(lock)) { + f = lock.getClass().getDeclaredField("writeLock"); + f.setAccessible(true); + WRITE_LOCK_FIELD = f; + } + Object v = f.get(lock); + return (v instanceof java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock) + ? (java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock) v : null; + } catch (Throwable t) { + return null; + } + } + + /** Find an edge between two nodes, checking all edge types (directed type 1 and undirected type 0). */ + static Edge findEdge(Graph g, Node source, Node target) { + Edge e = g.getEdge(source, target, 1); // directed + if (e == null) e = g.getEdge(source, target, 0); // undirected + if (e == null) e = g.getEdge(source, target); // default + return e; + } + + /** Locate a layout builder by name (see bestLayoutMatch for the matching rules). */ + private Layout findLayout(String algo) { + java.util.List builders = new java.util.ArrayList<>(); + java.util.List names = new java.util.ArrayList<>(); + for (LayoutBuilder b : Lookup.getDefault().lookupAll(LayoutBuilder.class)) { + builders.add(b); + names.add(b.getName()); + } + int idx = bestLayoutMatch(names, algo); + return idx >= 0 ? builders.get(idx).buildLayout() : null; + } + + /** + * Index of the best layout-name match for {@code query}, or -1. An exact match wins + * (case- and space-insensitive, so the documented "forceatlas2" matches "ForceAtlas 2" + * and "yifanhu" matches "Yifan Hu"); otherwise the first substring match. Space-folding + * is what makes the short names in the docs/skill actually resolve. Package-private + + * static for unit testing without the layout registry. + */ + static int bestLayoutMatch(java.util.List names, String query) { + if (query == null) return -1; + String q = query.toLowerCase().trim(); + String qns = q.replace(" ", ""); + if (qns.isEmpty()) return -1; + int substr = -1; + for (int i = 0; i < names.size(); i++) { + String name = names.get(i); + if (name == null) continue; + String n = name.toLowerCase(); + String nns = n.replace(" ", ""); + if (n.equals(q) || nns.equals(qns)) return i; + if (substr == -1 && (n.contains(q) || nns.contains(qns))) substr = i; + } + return substr; + } + + // ─── Project Management ────────────────────────────────────────── + + public JsonObject createProject(String name) { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + pc.newProject(); + Workspace ws = pc.getCurrentWorkspace(); + JsonObject r = success("Project created"); + r.addProperty("workspace_id", ws != null ? ws.getId() : -1); + return r; + }); + } + + public JsonObject openProject(String filePath) { + return runOnEDT(() -> { + File file = new File(filePath); + if (!file.exists()) return error("File not found: " + filePath); + try { + getProjectController().openProject(file); + return success("Project opened"); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject saveProject(String filePath) { + return runOnEDT(() -> { + try { + ProjectController pc = getProjectController(); + pc.saveProject(pc.getCurrentProject(), new File(filePath)); + return success("Project saved"); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject getProjectInfo() { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + if (ws != null) { + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + r.addProperty("has_project", true); + r.addProperty("workspace_id", ws.getId()); + r.addProperty("node_count", g.getNodeCount()); + r.addProperty("edge_count", g.getEdgeCount()); + r.addProperty("is_directed", gm.isDirected()); + r.addProperty("is_mixed", gm.isMixed()); + } else { + r.addProperty("has_project", false); + } + return r; + }); + } + + // ─── Workspace Management ──────────────────────────────────────── + + public JsonObject newWorkspace() { + return runOnEDT(() -> { + try { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) return error("No project open"); + Workspace ws = pc.newWorkspace(pc.getCurrentProject()); + pc.openWorkspace(ws); + JsonObject r = success("Workspace created"); + r.addProperty("workspace_id", ws.getId()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject listWorkspaces() { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) return error("No project open"); + JsonArray arr = new JsonArray(); + Workspace current = pc.getCurrentWorkspace(); + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + JsonObject o = new JsonObject(); + o.addProperty("id", ws.getId()); + o.addProperty("name", ws.getName() != null ? ws.getName() : "Workspace " + ws.getId()); + o.addProperty("current", ws.equals(current)); + GraphModel gm = getGraphController().getGraphModel(ws); + if (gm != null) { + Graph g = gm.getGraph(); + o.addProperty("node_count", g.getNodeCount()); + o.addProperty("edge_count", g.getEdgeCount()); + } else { + o.addProperty("node_count", 0); + o.addProperty("edge_count", 0); + } + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("workspaces", arr); + return r; + }); + } + + public JsonObject switchWorkspace(int index) { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) return error("No project open"); + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + pc.openWorkspace(ws); + return success("Switched to workspace " + ws.getId()); + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + public JsonObject deleteWorkspace(int index) { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) return error("No project open"); + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + pc.deleteWorkspace(ws); + return success("Workspace deleted"); + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + public JsonObject duplicateWorkspace(int index) { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) return error("No project open"); + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + try { + Workspace copy = pc.duplicateWorkspace(ws); + pc.openWorkspace(copy); + JsonObject r = success("Workspace duplicated"); + r.addProperty("workspace_id", copy.getId()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + public JsonObject renameWorkspace(int index, String name) { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) return error("No project open"); + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + try { + pc.renameWorkspace(ws, name); + return success("Workspace renamed to: " + name); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + // ─── Node Operations ───────────────────────────────────────────── + + public JsonObject addNode(String id, String label, Map attrs) { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + return addNodeToModel(getGraphController().getGraphModel(ws), id, label, attrs); + } + + /** Core node-add against an explicit model. Package-private + static so it is testable with a standalone GraphModel. */ + static JsonObject addNodeToModel(GraphModel gm, String id, String label, Map attrs) { + try { + Graph g = gm.getGraph(); + lockWrite(g); + try { + if (g.getNode(id) != null) return error("Node exists: " + id); + Node n = gm.factory().newNode(id); + n.setLabel(label != null ? label : id); + n.setX((float)(Math.random() * 1000 - 500)); + n.setY((float)(Math.random() * 1000 - 500)); + n.setSize(10f); + if (attrs != null) { + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + } + g.addNode(n); + JsonObject r = success("Node added"); + r.addProperty("node_id", id); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject addNodes(List> nodes) { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + return addNodesToModel(getGraphController().getGraphModel(ws), nodes); + } + + /** Core batch node-add against an explicit model (applies per-node attributes). */ + static JsonObject addNodesToModel(GraphModel gm, List> nodes) { + try { + Graph g = gm.getGraph(); + int added = 0, skipped = 0; + lockWrite(g); + try { + for (Map nd : nodes) { + String id = (String) nd.get("id"); + if (id == null || g.getNode(id) != null) { skipped++; continue; } + String label = (String) nd.getOrDefault("label", id); + Node n = gm.factory().newNode(id); + n.setLabel(label); + n.setX((float)(Math.random() * 1000 - 500)); + n.setY((float)(Math.random() * 1000 - 500)); + n.setSize(10f); + g.addNode(n); + Object attrsObj = nd.get("attributes"); + if (attrsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map attrs = (Map) attrsObj; + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + } + added++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("added", added); + r.addProperty("skipped", skipped); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject removeNode(String id) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = getGraphController().getGraphModel(ws).getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) return error("Node not found: " + id); + int edgesRemoved = g.getDegree(n); + g.removeNode(n); + JsonObject r = success("Node removed"); + r.addProperty("edges_removed", edgesRemoved); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject bulkRemoveNodes(List ids) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = getGraphController().getGraphModel(ws).getGraph(); + lockWrite(g); + try { + int removed = 0, notFound = 0; + for (String id : ids) { + Node n = g.getNode(id); + if (n == null) { notFound++; continue; } + g.removeNode(n); + removed++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("removed", removed); + r.addProperty("not_found", notFound); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject queryNodes(String attr, String val, int limit, int offset) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + g.readLock(); + try { + JsonArray arr = new JsonArray(); + int count = 0, skip = 0; + for (Node n : g.getNodes()) { + if (skip++ < offset) continue; + if (count >= limit) break; + JsonObject o = new JsonObject(); + o.addProperty("id", n.getId().toString()); + o.addProperty("label", n.getLabel()); + o.addProperty("x", n.x()); + o.addProperty("y", n.y()); + o.addProperty("size", n.size()); + o.addProperty("degree", g.getDegree(n)); + Color c = n.getColor(); + if (c != null) { + o.addProperty("r", c.getRed()); + o.addProperty("g", c.getGreen()); + o.addProperty("b", c.getBlue()); + o.addProperty("a", c.getAlpha()); + } + // Include all custom attributes + JsonObject attrs = new JsonObject(); + for (Column col : gm.getNodeTable()) { + if (col.isProperty()) continue; // skip built-in + Object v = n.getAttribute(col); + if (v != null) { + if (v instanceof Number) attrs.addProperty(col.getTitle(), (Number) v); + else if (v instanceof Boolean) attrs.addProperty(col.getTitle(), (Boolean) v); + else attrs.addProperty(col.getTitle(), v.toString()); + } + } + if (attrs.size() > 0) o.add("attributes", attrs); + arr.add(o); + count++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("total", g.getNodeCount()); + r.addProperty("count", count); + r.add("nodes", arr); + return r; + } finally { g.readUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject getNode(String id) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + Node n = g.getNode(id); + if (n == null) return error("Node not found: " + id); + JsonObject o = new JsonObject(); + o.addProperty("id", n.getId().toString()); + o.addProperty("label", n.getLabel()); + o.addProperty("x", n.x()); + o.addProperty("y", n.y()); + o.addProperty("size", n.size()); + o.addProperty("r", (int)(n.r() * 255)); + o.addProperty("g", (int)(n.g() * 255)); + o.addProperty("b", (int)(n.b() * 255)); + JsonObject attrs = new JsonObject(); + for (Column col : gm.getNodeTable()) { + if (col.isProperty()) continue; + Object v = n.getAttribute(col); + if (v == null) continue; + if (v instanceof Number) attrs.addProperty(col.getTitle(), (Number) v); + else if (v instanceof Boolean) attrs.addProperty(col.getTitle(), (Boolean) v); + else attrs.addProperty(col.getTitle(), v.toString()); + } + o.add("attributes", attrs); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("node", o); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setNodeLabel(String id, String label) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) return error("Node not found: " + id); + n.setLabel(label); + return success("Label set"); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setNodePosition(String id, float x, float y) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) return error("Node not found: " + id); + n.setX(x); + n.setY(y); + return success("Position set"); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject batchSetPositions(List> positions) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + int set = 0, notFound = 0; + for (Map pos : positions) { + String id = (String) pos.get("id"); + Node n = g.getNode(id); + if (n == null) { notFound++; continue; } + n.setX(((Number) pos.get("x")).floatValue()); + n.setY(((Number) pos.get("y")).floatValue()); + set++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("set", set); + r.addProperty("not_found", notFound); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + // ─── Edge Operations ───────────────────────────────────────────── + + public JsonObject addEdge(String src, String tgt, Double weight, boolean directed) { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + return addEdgeToModel(getGraphController().getGraphModel(ws), src, tgt, weight, directed); + } + + /** Core edge-add against an explicit model. Type and directedness are kept consistent. */ + static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight, boolean directed) { + try { + Graph g = gm.getGraph(); + lockWrite(g); + try { + Node s = g.getNode(src), t = g.getNode(tgt); + if (s == null) return error("Source not found: " + src); + if (t == null) return error("Target not found: " + tgt); + if (findEdge(g, s, t) != null) return error("Edge exists"); + Edge e = gm.factory().newEdge(s, t, directed ? 1 : 0, weight != null ? weight : 1.0, directed); + g.addEdge(e); + return success("Edge added"); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject addEdges(List> edges) { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + return addEdgesToModel(getGraphController().getGraphModel(ws), edges); + } + + /** Core batch edge-add against an explicit model (honors per-edge directed/label/attributes). */ + static JsonObject addEdgesToModel(GraphModel gm, List> edges) { + try { + Graph g = gm.getGraph(); + int added = 0, skipped = 0; + lockWrite(g); + try { + for (Map ed : edges) { + String src = (String) ed.get("source"); + String tgt = (String) ed.get("target"); + if (src == null || tgt == null) { skipped++; continue; } + Node s = g.getNode(src), t = g.getNode(tgt); + if (s == null || t == null || findEdge(g, s, t) != null) { skipped++; continue; } + Double w = ed.containsKey("weight") ? ((Number) ed.get("weight")).doubleValue() : 1.0; + boolean directed = !ed.containsKey("directed") || Boolean.TRUE.equals(ed.get("directed")); + Edge e = gm.factory().newEdge(s, t, directed ? 1 : 0, w, directed); + Object label = ed.get("label"); + if (label != null) e.setLabel(label.toString()); + g.addEdge(e); + Object attrsObj = ed.get("attributes"); + if (attrsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map attrs = (Map) attrsObj; + for (Map.Entry en : attrs.entrySet()) { + ensureColumnAndSet(gm.getEdgeTable(), e, en.getKey(), en.getValue()); + } + } + added++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("added", added); + r.addProperty("skipped", skipped); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject removeEdge(String source, String target) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source), t = g.getNode(target); + if (s == null || t == null) return error("Node not found"); + Edge e = findEdge(g, s, t); + if (e == null) return error("Edge not found"); + g.removeEdge(e); + return success("Edge removed"); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setEdgeWeight(String source, String target, double weight) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source), t = g.getNode(target); + if (s == null || t == null) return error("Node not found"); + Edge e = findEdge(g, s, t); + if (e == null) return error("Edge not found"); + e.setWeight(weight); + return success("Weight set to " + weight); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setEdgeLabel(String source, String target, String label) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source), t = g.getNode(target); + if (s == null || t == null) return error("Node not found"); + Edge e = findEdge(g, s, t); + if (e == null) return error("Edge not found"); + e.setLabel(label); + return success("Edge label set"); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject queryEdges(int limit, int offset) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + g.readLock(); + try { + JsonArray arr = new JsonArray(); + int count = 0, skip = 0; + for (Edge e : g.getEdges()) { + if (skip++ < offset) continue; + if (count >= limit) break; + JsonObject o = new JsonObject(); + o.addProperty("source", e.getSource().getId().toString()); + o.addProperty("target", e.getTarget().getId().toString()); + o.addProperty("weight", e.getWeight()); + o.addProperty("directed", e.isDirected()); + if (e.getLabel() != null) o.addProperty("label", e.getLabel()); + Color c = e.getColor(); + if (c != null) { + o.addProperty("r", c.getRed()); + o.addProperty("g", c.getGreen()); + o.addProperty("b", c.getBlue()); + } + // Include custom attributes + JsonObject attrs = new JsonObject(); + for (Column col : gm.getEdgeTable()) { + if (col.isProperty()) continue; + Object v = e.getAttribute(col); + if (v != null) { + if (v instanceof Number) attrs.addProperty(col.getTitle(), (Number) v); + else if (v instanceof Boolean) attrs.addProperty(col.getTitle(), (Boolean) v); + else attrs.addProperty(col.getTitle(), v.toString()); + } + } + if (attrs.size() > 0) o.add("attributes", attrs); + arr.add(o); + count++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("total", g.getEdgeCount()); + r.addProperty("count", count); + r.add("edges", arr); + return r; + } finally { g.readUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + // ─── Graph Stats ───────────────────────────────────────────────── + + public JsonObject getGraphStats() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + g.readLock(); + try { + int nc = g.getNodeCount(), ec = g.getEdgeCount(); + double density = nc > 1 ? (2.0 * ec) / (nc * (nc - 1)) : 0; + double avgDeg = nc > 0 ? (2.0 * ec) / nc : 0; + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("node_count", nc); + r.addProperty("edge_count", ec); + r.addProperty("density", density); + r.addProperty("average_degree", avgDeg); + r.addProperty("is_directed", gm.isDirected()); + return r; + } finally { g.readUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + // ─── Graph Type ────────────────────────────────────────────────── + + public JsonObject getGraphType() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("directed", gm.isDirected()); + r.addProperty("undirected", gm.isUndirected()); + r.addProperty("mixed", gm.isMixed()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + // ─── Attribute / Column Management ─────────────────────────────── + + public JsonObject getColumns(String target) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Table table = "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable(); + JsonArray arr = new JsonArray(); + for (Column col : table) { + JsonObject o = new JsonObject(); + o.addProperty("id", col.getId()); + o.addProperty("title", col.getTitle()); + o.addProperty("type", col.getTypeClass().getSimpleName()); + o.addProperty("property", col.isProperty()); + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("target", target); + r.add("columns", arr); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject addColumn(String name, String type, String target) { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + return addColumnToModel(currentGraphModel(), name, type, target); + } + + /** + * Add a column under the graph write lock. Taking the lock matters for ordering: + * ensureColumnAndSet() also adds columns while holding the write lock, so doing it + * lock-free here created an A-holds-graph/wants-column vs B-holds-column/wants-graph + * deadlock under concurrent requests. Package-private + static for unit testing. + */ + static JsonObject addColumnToModel(GraphModel gm, String name, String type, String target) { + try { + Table table = "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable(); + Class cls = typeStringToClass(type); + if (cls == null) return error("Unknown type: " + type + ". Use: string, integer, double, float, boolean, long"); + Graph g = gm.getGraph(); + lockWrite(g); + try { + if (table.getColumn(name) != null) return error("Column already exists: " + name); + table.addColumn(name, cls); + } finally { g.writeUnlock(); } + return success("Column '" + name + "' added"); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setNodeAttributes(String id, Map attrs) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) return error("Node not found: " + id); + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + return success("Attributes set on node " + id); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject batchSetNodeAttributes(List> updates) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + int set = 0, notFound = 0; + for (Map update : updates) { + String id = (String) update.get("id"); + Node n = g.getNode(id); + if (n == null) { notFound++; continue; } + @SuppressWarnings("unchecked") + Map attrs = (Map) update.get("attributes"); + if (attrs != null) { + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + } + set++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("set", set); + r.addProperty("not_found", notFound); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setEdgeAttributes(String source, String target, Map attrs) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source), t = g.getNode(target); + if (s == null || t == null) return error("Node not found"); + Edge e = findEdge(g, s, t); + if (e == null) return error("Edge not found"); + for (Map.Entry entry : attrs.entrySet()) { + ensureColumnAndSet(gm.getEdgeTable(), e, entry.getKey(), entry.getValue()); + } + return success("Attributes set on edge"); + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + static void ensureColumnAndSet(Table table, Object element, String key, Object value) { + Column col = table.getColumn(key); + if (col == null) { + Class cls = String.class; + if (value instanceof Number) { + if (value instanceof Integer) cls = Integer.class; + else if (value instanceof Long) cls = Long.class; + else if (value instanceof Float) cls = Float.class; + else cls = Double.class; + } else if (value instanceof Boolean) { + cls = Boolean.class; + } + col = table.addColumn(key, cls); + } + // Convert value to column type + Object converted = convertToColumnType(value, col.getTypeClass()); + if (element instanceof Node) ((Node) element).setAttribute(col, converted); + else if (element instanceof Edge) ((Edge) element).setAttribute(col, converted); + } + + static Object convertToColumnType(Object value, Class targetType) { + if (value == null) return null; + if (targetType.isInstance(value)) return value; + String s = value.toString(); + try { + if (targetType == Integer.class) return (int) Double.parseDouble(s); + if (targetType == Long.class) return (long) Double.parseDouble(s); + if (targetType == Float.class) return (float) Double.parseDouble(s); + if (targetType == Double.class) return Double.parseDouble(s); + if (targetType == Boolean.class) return Boolean.parseBoolean(s); + } catch (Exception e) { /* fall through */ } + return s; + } + + static Class typeStringToClass(String type) { + if (type == null) return null; + switch (type.toLowerCase()) { + case "string": return String.class; + case "integer": case "int": return Integer.class; + case "double": return Double.class; + case "float": return Float.class; + case "boolean": case "bool": return Boolean.class; + case "long": return Long.class; + default: return null; + } + } + + // ─── Appearance: Individual Node/Edge Styling ──────────────────── + + public JsonObject setNodeColor(String id, int r, int g, int b, int a) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph graph = currentGraphModel().getGraph(); + lockWrite(graph); + try { + Node n = graph.getNode(id); + if (n == null) return error("Node not found: " + id); + n.setColor(new Color(r, g, b, a)); + return success("Node color set"); + } finally { graph.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setNodeSize(String id, float size) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph graph = currentGraphModel().getGraph(); + lockWrite(graph); + try { + Node n = graph.getNode(id); + if (n == null) return error("Node not found: " + id); + n.setSize(size); + return success("Node size set to " + size); + } finally { graph.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setEdgeColor(String source, String target, int r, int g, int b, int a) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph graph = currentGraphModel().getGraph(); + lockWrite(graph); + try { + Node s = graph.getNode(source), t = graph.getNode(target); + if (s == null || t == null) return error("Node not found"); + Edge e = findEdge(graph, s, t); + if (e == null) return error("Edge not found"); + e.setColor(new Color(r, g, b, a)); + return success("Edge color set"); + } finally { graph.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject batchSetNodeColors(List> nodeColors) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + Graph graph = currentGraphModel().getGraph(); + lockWrite(graph); + try { + int set = 0, notFound = 0; + for (Map nc : nodeColors) { + String id = (String) nc.get("id"); + Node n = graph.getNode(id); + if (n == null) { notFound++; continue; } + int r = ((Number) nc.get("r")).intValue(); + int g = ((Number) nc.get("g")).intValue(); + int b = ((Number) nc.get("b")).intValue(); + int a = nc.containsKey("a") ? ((Number) nc.get("a")).intValue() : 255; + n.setColor(new Color(r, g, b, a)); + set++; + } + JsonObject res = new JsonObject(); + res.addProperty("success", true); + res.addProperty("set", set); + res.addProperty("not_found", notFound); + return res; + } finally { graph.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject resetAppearance(int r, int g, int b, float size) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph graph = currentGraphModel().getGraph(); + Color defaultColor = new Color(r, g, b); + lockWrite(graph); + try { + for (Node n : graph.getNodes()) { + n.setColor(defaultColor); + n.setSize(size); + } + } finally { graph.writeUnlock(); } + return success("Appearance reset for all nodes"); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + // ─── Appearance: Color/Size by Attribute ───────────────────────── + + public JsonObject colorByPartition(String columnName, Map colorMap) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = gm.getNodeTable().getColumn(columnName); + if (col == null) return error("Column not found: " + columnName); + + // Collect distinct values + java.util.Map palette = new java.util.LinkedHashMap<>(); + if (colorMap != null && !colorMap.isEmpty()) { + for (Map.Entry e : colorMap.entrySet()) { + int[] c = e.getValue(); + palette.put(e.getKey(), new Color(c[0], c[1], c[2])); + } + } else { + // Auto-generate palette + java.util.Set values = new java.util.LinkedHashSet<>(); + Node[] allNodes = graph.getNodes().toArray(); + for (Node n : allNodes) { + Object v = n.getAttribute(col); + if (v != null) values.add(v.toString()); + } + + Color[] defaultPalette = { + new Color(31, 119, 180), new Color(255, 127, 14), new Color(44, 160, 44), + new Color(214, 39, 40), new Color(148, 103, 189), new Color(140, 86, 75), + new Color(227, 119, 194), new Color(127, 127, 127), new Color(188, 189, 34), + new Color(23, 190, 207), new Color(174, 199, 232), new Color(255, 187, 120) + }; + int idx = 0; + for (String v : values) { + palette.put(v, defaultPalette[idx % defaultPalette.length]); + idx++; + } + } + + int colored = 0; + lockWrite(graph); + try { + for (Node n : graph.getNodes()) { + Object v = n.getAttribute(col); + if (v != null) { + Color c = palette.get(v.toString()); + if (c != null) { + n.setColor(c); + colored++; + } + } + } + } finally { graph.writeUnlock(); } + JsonObject r = success("Colored " + colored + " nodes by " + columnName); + r.addProperty("partitions", palette.size()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + /** + * Min and max over the numeric values of {@code col}, as {@code [min, max]}, or null when + * the column holds no numeric values. Seeded with infinities so a column whose values are + * entirely negative ranks correctly — the old {@code Double.MIN_VALUE} seed (smallest + * positive double) silently broke that case. Package-private + static for unit testing. + */ + static double[] numericRange(Graph g, Column col) { + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + g.readLock(); + try { + for (Node n : g.getNodes()) { + Object v = n.getAttribute(col); + if (v instanceof Number) { + double d = ((Number) v).doubleValue(); + if (d < min) min = d; + if (d > max) max = d; + } + } + } finally { g.readUnlock(); } + return min == Double.POSITIVE_INFINITY ? null : new double[]{min, max}; + } + + public JsonObject colorByRanking(String columnName, int rMin, int gMin, int bMin, int rMax, int gMax, int bMax) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = gm.getNodeTable().getColumn(columnName); + if (col == null) return error("Column not found: " + columnName); + + double[] mm = numericRange(graph, col); + if (mm == null) return error("No numeric values in column " + columnName); + double min = mm[0], max = mm[1]; + double range = max - min; + if (range == 0) range = 1; + + int colored = 0; + lockWrite(graph); + try { + for (Node n : graph.getNodes()) { + Object v = n.getAttribute(col); + if (v instanceof Number) { + double t = (((Number) v).doubleValue() - min) / range; + int r = (int)(rMin + t * (rMax - rMin)); + int g = (int)(gMin + t * (gMax - gMin)); + int b = (int)(bMin + t * (bMax - bMin)); + n.setColor(new Color( + Math.max(0, Math.min(255, r)), + Math.max(0, Math.min(255, g)), + Math.max(0, Math.min(255, b)) + )); + colored++; + } + } + } finally { graph.writeUnlock(); } + JsonObject res = success("Colored " + colored + " nodes by ranking on " + columnName); + res.addProperty("min_value", min); + res.addProperty("max_value", max); + return res; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject sizeByRanking(String columnName, float minSize, float maxSize) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = gm.getNodeTable().getColumn(columnName); + if (col == null) return error("Column not found: " + columnName); + + double[] mm = numericRange(graph, col); + if (mm == null) return error("No numeric values in column " + columnName); + double min = mm[0], max = mm[1]; + double range = max - min; + if (range == 0) range = 1; + + int sized = 0; + lockWrite(graph); + try { + for (Node n : graph.getNodes()) { + Object v = n.getAttribute(col); + if (v instanceof Number) { + double t = (((Number) v).doubleValue() - min) / range; + n.setSize((float)(minSize + t * (maxSize - minSize))); + sized++; + } + } + } finally { graph.writeUnlock(); } + JsonObject res = success("Sized " + sized + " nodes by " + columnName); + res.addProperty("min_value", min); + res.addProperty("max_value", max); + return res; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + // ─── Layout ────────────────────────────────────────────────────── + + public JsonObject runLayout(String algo, int iterations) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = getGraphController().getGraphModel(ws); + Layout layout = findLayout(algo); + if (layout == null) return error("Layout not found: " + algo); + layout.setGraphModel(gm); + final Layout fl = layout; + final int iters = iterations > 0 ? iterations : 1000; + if (!layoutRunning.compareAndSet(false, true)) return error("Layout already running"); + currentLayoutName = algo; + layoutFuture = layoutExecutor.submit(() -> { + try { + fl.initAlgo(); + for (int i = 0; i < iters && layoutRunning.get() && fl.canAlgo(); i++) fl.goAlgo(); + fl.endAlgo(); + } catch (Exception e) { LOGGER.log(Level.WARNING, "Layout error", e); } + finally { layoutRunning.set(false); currentLayoutName = null; } + }); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("layout", algo); + r.addProperty("status", "running"); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject stopLayout() { + if (!layoutRunning.get()) return success("No layout running"); + layoutRunning.set(false); + if (layoutFuture != null) layoutFuture.cancel(true); + return success("Layout stopped"); + } + + public JsonObject getLayoutStatus() { + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("running", layoutRunning.get()); + if (currentLayoutName != null) r.addProperty("layout", currentLayoutName); + return r; + } + + public JsonObject getAvailableLayouts() { + JsonArray arr = new JsonArray(); + for (LayoutBuilder b : Lookup.getDefault().lookupAll(LayoutBuilder.class)) { + JsonObject o = new JsonObject(); + o.addProperty("name", b.getName()); + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("layouts", arr); + return r; + } + + public JsonObject getLayoutProperties(String algo) { + try { + Layout layout = findLayout(algo); + if (layout == null) return error("Layout not found: " + algo); + // Need a graph model for the layout to report properties + Workspace ws = currentWorkspace(); + if (ws != null) layout.setGraphModel(currentGraphModel()); + + JsonArray arr = new JsonArray(); + LayoutProperty[] props = layout.getProperties(); + if (props != null) { + for (LayoutProperty prop : props) { + JsonObject o = new JsonObject(); + o.addProperty("name", prop.getCanonicalName() != null ? prop.getCanonicalName() : prop.getProperty().getDisplayName()); + o.addProperty("display_name", prop.getProperty().getDisplayName()); + o.addProperty("type", prop.getProperty().getValueType().getSimpleName()); + Object val = prop.getProperty().getValue(); + if (val != null) o.addProperty("value", val.toString()); + String desc = prop.getProperty().getShortDescription(); + if (desc != null) o.addProperty("description", desc); + arr.add(o); + } + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("algorithm", algo); + r.add("properties", arr); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setLayoutProperties(String algo, Map properties, int iterations) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Layout layout = findLayout(algo); + if (layout == null) return error("Layout not found: " + algo); + layout.setGraphModel(gm); + + // Set properties + if (properties != null) { + LayoutProperty[] props = layout.getProperties(); + if (props != null) { + for (LayoutProperty prop : props) { + String canonicalName = prop.getCanonicalName() != null ? prop.getCanonicalName() : ""; + String displayName = prop.getProperty().getDisplayName(); + // Extract middle key from "AlgoName.propertyKey.name" pattern + String canonicalKey = ""; + if (!canonicalName.isEmpty()) { + String[] parts = canonicalName.split("\\."); + if (parts.length >= 3) canonicalKey = parts[parts.length - 2]; + } + Object val = properties.get(canonicalKey); + if (val == null && !canonicalName.isEmpty()) val = properties.get(canonicalName); + if (val == null) val = properties.get(displayName); + if (val == null) { + for (Map.Entry e : properties.entrySet()) { + String k = e.getKey(); + if ((!canonicalKey.isEmpty() && k.equalsIgnoreCase(canonicalKey)) + || k.equalsIgnoreCase(displayName) + || (!canonicalName.isEmpty() && k.equalsIgnoreCase(canonicalName))) { + val = e.getValue(); + break; + } + } + } + if (val != null) { + Class type = prop.getProperty().getValueType(); + Object converted = convertLayoutProperty(val, type); + if (converted != null) prop.getProperty().setValue(converted); + } + } + } + } + + // Run layout with configured properties + final Layout fl = layout; + final int iters = iterations > 0 ? iterations : 1000; + if (!layoutRunning.compareAndSet(false, true)) return error("Layout already running"); + currentLayoutName = algo; + layoutFuture = layoutExecutor.submit(() -> { + try { + fl.initAlgo(); + for (int i = 0; i < iters && layoutRunning.get() && fl.canAlgo(); i++) fl.goAlgo(); + fl.endAlgo(); + } catch (Exception e) { LOGGER.log(Level.WARNING, "Layout error", e); } + finally { layoutRunning.set(false); currentLayoutName = null; } + }); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("layout", algo); + r.addProperty("status", "running"); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + static Object convertLayoutProperty(Object val, Class type) { + if (val == null) return null; + String s = val.toString(); + try { + if (type == Boolean.class || type == boolean.class) return Boolean.parseBoolean(s); + if (type == Integer.class || type == int.class) return (int) Double.parseDouble(s); + if (type == Double.class || type == double.class) return Double.parseDouble(s); + if (type == Float.class || type == float.class) return (float) Double.parseDouble(s); + if (type == Long.class || type == long.class) return (long) Double.parseDouble(s); + if (type == String.class) return s; + } catch (Exception e) { /* fall through */ } + return null; + } + + // ─── Statistics ────────────────────────────────────────────────── + + private JsonObject runStatistic(String builderName, Map params) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + + // Find statistics builder by name + StatisticsBuilder matchedBuilder = null; + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + String name = sb.getName(); + LOGGER.fine("MCP: Found StatisticsBuilder: " + name + " (" + sb.getClass().getName() + ")"); + if (name.equalsIgnoreCase(builderName) || sb.getClass().getSimpleName().toLowerCase().contains(builderName.toLowerCase())) { + matchedBuilder = sb; + break; + } + } + if (matchedBuilder == null) { + // Also try matching by statistics class name + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + try { + Statistics stat = sb.getStatistics(); + if (stat.getClass().getSimpleName().equalsIgnoreCase(builderName)) { + matchedBuilder = sb; + break; + } + } catch (Exception e) { /* skip */ } + } + } + if (matchedBuilder == null) return error("Statistics not found: " + builderName); + + Statistics stat = matchedBuilder.getStatistics(); + + // Set parameters via reflection + if (params != null) { + for (Map.Entry e : params.entrySet()) { + setViaReflection(stat, e.getKey(), e.getValue()); + } + } + + // Execute + stat.execute(gm); + + // Build result + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("statistic", matchedBuilder.getName()); + + // Try to get common result values via reflection + tryAddResult(r, stat, "getModularity", "modularity"); + tryAddResult(r, stat, "getAverageDegree", "average_degree"); + tryAddResult(r, stat, "getPathLength", "average_path_length"); + tryAddResult(r, stat, "getDiameter", "diameter"); + tryAddResult(r, stat, "getRadius", "radius"); + tryAddResult(r, stat, "getAverageClusteringCoefficient", "average_clustering_coefficient"); + tryAddResult(r, stat, "getConnectedComponentsCount", "connected_components"); + + // Get the report + try { + String report = stat.getReport(); + if (report != null) { + r.addProperty("report_available", true); + r.addProperty("report_html", report); + } + } catch (Exception e) { /* no report */ } + + return r; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Statistic execution failed", e); + return error("Failed: " + e.getMessage()); + } + } + + private void setViaReflection(Object obj, String setter, Object value) { + String methodName = "set" + setter.substring(0, 1).toUpperCase() + setter.substring(1); + try { + for (java.lang.reflect.Method m : obj.getClass().getMethods()) { + if (m.getName().equals(methodName) && m.getParameterCount() == 1) { + Class paramType = m.getParameterTypes()[0]; + Object converted = convertLayoutProperty(value, paramType); + if (converted != null) m.invoke(obj, converted); + return; + } + } + } catch (Exception e) { + LOGGER.fine("Could not set " + methodName + ": " + e.getMessage()); + } + } + + private void tryAddResult(JsonObject r, Object obj, String getter, String jsonKey) { + try { + java.lang.reflect.Method m = obj.getClass().getMethod(getter); + Object val = m.invoke(obj); + if (val instanceof Number) r.addProperty(jsonKey, (Number) val); + else if (val instanceof Boolean) r.addProperty(jsonKey, (Boolean) val); + else if (val != null) r.addProperty(jsonKey, val.toString()); + } catch (NoSuchMethodException e) { /* method not available for this statistic */ } + catch (Exception e) { LOGGER.fine("Could not get " + getter + ": " + e.getMessage()); } + } + + public JsonObject computeModularity(double resolution) { + java.util.Map params = new java.util.HashMap<>(); + params.put("resolution", resolution); + params.put("useWeight", false); + return runStatistic("Modularity", params); + } + + public JsonObject computeDegree() { + return runStatistic("Degree", null); + } + + public JsonObject computeBetweenness() { + return runStatistic("GraphDistance", null); + } + + public JsonObject computePageRank() { + return runStatistic("PageRank", null); + } + + public JsonObject computeConnectedComponents() { + return runStatistic("ConnectedComponents", null); + } + + public JsonObject computeClusteringCoefficient() { + return runStatistic("ClusteringCoefficient", null); + } + + public JsonObject computeAvgPathLength() { + java.util.Map params = new java.util.HashMap<>(); + params.put("directed", false); + return runStatistic("GraphDistance", params); + } + + public JsonObject computeHITS() { + return runStatistic("HITS", null); + } + + public JsonObject computeEigenvectorCentrality() { + return runStatistic("EigenvectorCentrality", null); + } + + // ─── Filters ───────────────────────────────────────────────────── + + public JsonObject filterByDegreeRange(int minDegree, int maxDegree, boolean dryRun) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph g = currentGraphModel().getGraph(); + Node[] allNodes = g.getNodes().toArray(); + java.util.List toRemove = new java.util.ArrayList<>(); + for (Node n : allNodes) { + int deg = g.getDegree(n); + if (deg < minDegree || (maxDegree > 0 && deg > maxDegree)) { + toRemove.add(n); + } + } + if (dryRun) { + JsonObject r = success("Dry run: " + toRemove.size() + " nodes would be removed"); + r.addProperty("would_remove", toRemove.size()); + r.addProperty("would_remain", g.getNodeCount() - toRemove.size()); + r.addProperty("dry_run", true); + return r; + } + lockWrite(g); + try { for (Node n : toRemove) g.removeNode(n); } + finally { g.writeUnlock(); } + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + if (pc != null) pc.refreshPreview(ws); + JsonObject r = success("Filtered by degree [" + minDegree + ", " + maxDegree + "]"); + r.addProperty("removed", toRemove.size()); + r.addProperty("remaining_nodes", g.getNodeCount()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject filterByEdgeWeight(double minWeight, double maxWeight, boolean dryRun) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph g = currentGraphModel().getGraph(); + Edge[] allEdges = g.getEdges().toArray(); + java.util.List toRemove = new java.util.ArrayList<>(); + for (Edge e : allEdges) { + double w = e.getWeight(); + if (w < minWeight || (maxWeight > 0 && w > maxWeight)) { + toRemove.add(e); + } + } + if (dryRun) { + JsonObject r = success("Dry run: " + toRemove.size() + " edges would be removed"); + r.addProperty("would_remove", toRemove.size()); + r.addProperty("would_remain", g.getEdgeCount() - toRemove.size()); + r.addProperty("dry_run", true); + return r; + } + lockWrite(g); + try { for (Edge e : toRemove) g.removeEdge(e); } + finally { g.writeUnlock(); } + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + if (pc != null) pc.refreshPreview(ws); + JsonObject r = success("Filtered edges by weight [" + minWeight + ", " + maxWeight + "]"); + r.addProperty("removed", toRemove.size()); + r.addProperty("remaining_edges", g.getEdgeCount()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + // ─── Preview Settings ──────────────────────────────────────────── + + public JsonObject getPreviewSettings() { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc.getModel(ws); + if (pm == null) return error("Preview model not available"); + + JsonObject settings = new JsonObject(); + // Get commonly used properties + for (PreviewProperty prop : pm.getProperties().getProperties()) { + String name = prop.getName(); + Object val = prop.getValue(); + if (val != null) { + if (val instanceof Color) { + Color c = (Color) val; + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue())); + } else if (val instanceof Number) { + settings.addProperty(name, (Number) val); + } else if (val instanceof Boolean) { + settings.addProperty(name, (Boolean) val); + } else if (val instanceof java.awt.Font) { + java.awt.Font f = (java.awt.Font) val; + String style = f.isBold() && f.isItalic() ? "BoldItalic" : f.isBold() ? "Bold" : f.isItalic() ? "Italic" : "Plain"; + settings.addProperty(name, f.getFamily() + " " + f.getSize() + " " + style); + } else if (val instanceof EdgeColor) { + EdgeColor ec = (EdgeColor) val; + if (ec.getMode() == EdgeColor.Mode.ORIGINAL) settings.addProperty(name, "original"); + else if (ec.getMode() == EdgeColor.Mode.MIXED) settings.addProperty(name, "mixed"); + else if (ec.getCustomColor() != null) { + Color c = ec.getCustomColor(); + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue())); + } else settings.addProperty(name, ec.getMode().toString().toLowerCase()); + } else if (val instanceof DependantColor) { + DependantColor dc = (DependantColor) val; + if (dc.getMode() == DependantColor.Mode.PARENT) settings.addProperty(name, "parent"); + else if (dc.getMode() == DependantColor.Mode.DARKER) settings.addProperty(name, "darker"); + else if (dc.getCustomColor() != null) { + Color c = dc.getCustomColor(); + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue())); + } else settings.addProperty(name, "parent"); + } else if (val instanceof DependantOriginalColor) { + DependantOriginalColor doc = (DependantOriginalColor) val; + if (doc.getMode() == DependantOriginalColor.Mode.ORIGINAL) settings.addProperty(name, "original"); + else if (doc.getMode() == DependantOriginalColor.Mode.PARENT) settings.addProperty(name, "parent"); + else if (doc.getCustomColor() != null) { + Color c = doc.getCustomColor(); + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue())); + } else settings.addProperty(name, "original"); + } else { + settings.addProperty(name, val.toString()); + } + } + } + + // Include background color if not already captured by the main loop + try { + Object bgVal = pm.getProperties().getValue("background.color"); + if (bgVal instanceof Color) { + Color c = (Color) bgVal; + settings.addProperty("background.color", String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue())); + } + } catch (Exception ignored) {} + + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("settings", settings); + return r; + } catch (Exception e) { + return error("Failed: " + e.getMessage()); + } + }); + } + + public JsonObject setPreviewSettings(Map settings) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc.getModel(ws); + if (pm == null) return error("Preview model not available"); + + int set = 0; + for (Map.Entry e : settings.entrySet()) { + String key = e.getKey(); + Object val = e.getValue(); + if (val == null) continue; // Skip null values to avoid corrupting preview model + + // Background color: store for PNG compositing and try to set on preview model + if ("background.color".equalsIgnoreCase(key) || "backgroundColor".equalsIgnoreCase(key)) { + try { + String hex = val.toString().trim(); + if (hex.startsWith("#")) hex = hex.substring(1); + Color bgColor = new Color(Integer.parseInt(hex, 16)); + exportBackgroundColor = bgColor; // always store for exportPng compositing + PreviewProperty bgProp = pm.getProperties().getProperty("background.color"); + if (bgProp != null) { + bgProp.setValue(bgColor); + } else { + pm.getProperties().putValue("background.color", bgColor); + } + set++; + } catch (NumberFormatException nfe) { + LOGGER.warning("MCP: Invalid background color: " + val); + } + continue; + } + + PreviewProperty prop = pm.getProperties().getProperty(key); + if (prop != null) { + // Convert value based on property type + Class type = prop.getType(); + try { + if (type == Color.class && val instanceof String) { + String hex = (String) val; + if (hex.startsWith("#")) hex = hex.substring(1); + prop.setValue(new Color(Integer.parseInt(hex, 16))); + } else if (type == Boolean.class || type == boolean.class) { + prop.setValue(Boolean.parseBoolean(val.toString())); + } else if (type == Float.class || type == float.class) { + prop.setValue(Float.parseFloat(val.toString())); + } else if (type == Integer.class || type == int.class) { + prop.setValue(Integer.parseInt(val.toString())); + } else if (type == java.awt.Font.class && val instanceof String) { + // Parse font string like "Courier New 12 Bold" -> Font object + // Everything before first digit = name, first number = size, rest = style + String fontStr = val.toString().trim(); + String name = "Arial"; + int fontSize = 12; + int fontStyle = java.awt.Font.PLAIN; + int numStart = -1; + for (int ci = 0; ci < fontStr.length(); ci++) { + if (Character.isDigit(fontStr.charAt(ci))) { numStart = ci; break; } + } + if (numStart > 0) { + name = fontStr.substring(0, numStart).trim(); + String[] rest = fontStr.substring(numStart).trim().split("\\s+"); + try { fontSize = Integer.parseInt(rest[0]); } catch (NumberFormatException ignored) {} + for (int pi = 1; pi < rest.length; pi++) { + if ("Bold".equalsIgnoreCase(rest[pi])) fontStyle |= java.awt.Font.BOLD; + else if ("Italic".equalsIgnoreCase(rest[pi])) fontStyle |= java.awt.Font.ITALIC; + } + } else if (numStart < 0) { + name = fontStr; + } + prop.setValue(new java.awt.Font(name, fontStyle, fontSize)); + } else if (type == java.awt.Font.class) { + continue; // Non-string font value, skip + } else if (type == DependantColor.class && val instanceof String) { + String s = val.toString().trim().toLowerCase(); + if ("parent".equals(s)) { + prop.setValue(new DependantColor(DependantColor.Mode.PARENT)); + } else if ("darker".equals(s)) { + prop.setValue(new DependantColor(DependantColor.Mode.DARKER)); + } else if (s.startsWith("#")) { + prop.setValue(new DependantColor(new Color(Integer.parseInt(s.substring(1), 16)))); + } else { continue; } + } else if (type == DependantOriginalColor.class && val instanceof String) { + String s = val.toString().trim().toLowerCase(); + if ("parent".equals(s)) { + prop.setValue(new DependantOriginalColor(DependantOriginalColor.Mode.PARENT)); + } else if ("original".equals(s)) { + prop.setValue(new DependantOriginalColor(DependantOriginalColor.Mode.ORIGINAL)); + } else if (s.startsWith("#")) { + prop.setValue(new DependantOriginalColor(new Color(Integer.parseInt(s.substring(1), 16)))); + } else { continue; } + } else if (type == EdgeColor.class && val instanceof String) { + // For "source"/"target": color edges individually instead of using + // EdgeColor mode (which corrupts SVG rendering in Gephi 0.10) + String s = val.toString().trim().toLowerCase(); + if ("source".equals(s) || "target".equals(s)) { + boolean useSource = "source".equals(s); + Graph graph = currentGraphModel().getGraph(); + Node[] graphNodes = graph.getNodes().toArray(); + Edge[] graphEdges = graph.getEdges().toArray(); + java.util.Map nodeColors = new java.util.HashMap<>(); + for (Node n : graphNodes) nodeColors.put(n, n.getColor()); + for (Edge edge : graphEdges) { + Node ref = useSource ? edge.getSource() : edge.getTarget(); + Color c = nodeColors.get(ref); + if (c != null) edge.setColor(c); + } + prop.setValue(new EdgeColor(EdgeColor.Mode.ORIGINAL)); + } else if ("mixed".equals(s)) { + prop.setValue(new EdgeColor(EdgeColor.Mode.MIXED)); + } else if ("original".equals(s)) { + prop.setValue(new EdgeColor(EdgeColor.Mode.ORIGINAL)); + } else if (s.startsWith("#")) { + prop.setValue(new EdgeColor(new Color(Integer.parseInt(s.substring(1), 16)))); + } else { continue; } + } else { + continue; // Skip unknown types + } + set++; + } catch (NumberFormatException nfe) { + LOGGER.warning("MCP: Invalid number/color value for " + key + ": " + val); + continue; + } catch (Exception ex) { + LOGGER.warning("MCP: Failed to set preview property " + key + ": " + ex.getMessage()); + continue; + } + } + } + JsonObject r = success("Set " + set + " preview properties"); + r.addProperty("properties_set", set); + return r; + } catch (Exception e) { + return error("Failed: " + e.getMessage()); + } + }); + } + + // ─── Export ─────────────────────────────────────────────────────── + + public JsonObject exportGexf(String filePath) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("gexf"); + if (exporter == null) return error("GEXF exporter not available"); + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(true); + ((GraphExporter) exporter).setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + return success("Exported to " + filePath); + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + + public JsonObject exportPng(String filePath, int w, int h) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + // Refresh preview first + PreviewController previewController = Lookup.getDefault().lookup(PreviewController.class); + if (previewController != null) { + previewController.refreshPreview(ws); + } + + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("png"); + if (exporter == null) return error("PNG exporter not available"); + + // Set dimensions via reflection (PNGExporter is in plugin, not API) + setViaReflection(exporter, "width", w); + setViaReflection(exporter, "height", h); + + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setWorkspace(ws); + } + + ec.exportFile(new File(filePath), exporter); + + // Post-process: composite onto background color if one was set via setPreviewSettings. + // Gephi's PNG exporter renders a transparent background; this fills it. + Color bgColor = exportBackgroundColor; + if (bgColor != null && !bgColor.equals(Color.WHITE)) { + BufferedImage exported = ImageIO.read(new File(filePath)); + if (exported != null) { + BufferedImage result = new BufferedImage(exported.getWidth(), exported.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = result.createGraphics(); + g2d.setColor(bgColor); + g2d.fillRect(0, 0, result.getWidth(), result.getHeight()); + g2d.drawImage(exported, 0, 0, null); + g2d.dispose(); + ImageIO.write(result, "PNG", new File(filePath)); + } + } + + return success("Exported to " + filePath); + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + + public JsonObject exportPdf(String filePath, int w, int h) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph g = currentGraphModel().getGraph(); + if (g.getNodeCount() == 0) return error("Cannot export PDF: graph has no nodes"); + PreviewController previewController = Lookup.getDefault().lookup(PreviewController.class); + if (previewController != null) previewController.refreshPreview(ws); + + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("pdf"); + if (exporter == null) return error("PDF exporter not available"); + if (w > 0) setViaReflection(exporter, "width", w); + if (h > 0) setViaReflection(exporter, "height", h); + if (exporter instanceof GraphExporter) ((GraphExporter) exporter).setWorkspace(ws); + ec.exportFile(new File(filePath), exporter); + return success("Exported to " + filePath); + } catch (IllegalArgumentException e) { + return error("Export failed: graph nodes may not be positioned — run a layout first"); + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + + public JsonObject exportSvg(String filePath) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + PreviewController previewController = Lookup.getDefault().lookup(PreviewController.class); + if (previewController != null) previewController.refreshPreview(ws); + + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("svg"); + if (exporter == null) return error("SVG exporter not available"); + if (exporter instanceof GraphExporter) ((GraphExporter) exporter).setWorkspace(ws); + ec.exportFile(new File(filePath), exporter); + return success("Exported to " + filePath); + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + + public JsonObject exportGraphml(String filePath) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("graphml"); + if (exporter == null) return error("GraphML exporter not available"); + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(true); + ((GraphExporter) exporter).setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + return success("Exported to " + filePath); + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + + public JsonObject exportCsv(String filePath, String separator, String target) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + // Always use manual export — Gephi's built-in CSV exporter produces an adjacency matrix + return exportCsvManual(filePath, separator, target); + }); + } + + private JsonObject exportCsvManual(String filePath, String separator, String target) { + try { + String csvText = buildCsv(currentGraphModel(), separator, target); + try (java.io.Writer fw = new java.io.OutputStreamWriter( + new java.io.FileOutputStream(filePath), java.nio.charset.StandardCharsets.UTF_8)) { + fw.write(csvText); + } + return success("Exported to " + filePath); + } catch (Exception e) { + return error("CSV export failed: " + e.getMessage()); + } + } + + /** Build node/edge CSV text from a model (RFC 4180 quoted). Package-private + static for unit testing. */ + static String buildCsv(GraphModel gm, String separator, String target) { + Graph g = gm.getGraph(); + String sep = separator != null ? separator : ","; + StringBuilder sb = new StringBuilder(); + { + if (!"edges".equalsIgnoreCase(target)) { + // Export nodes + sb.append(csv("Id", sep)).append(sep).append(csv("Label", sep)); + for (Column col : gm.getNodeTable()) { + if (!col.isProperty()) sb.append(sep).append(csv(col.getTitle(), sep)); + } + sb.append("\n"); + g.readLock(); + try { + for (Node n : g.getNodes()) { + sb.append(csv(String.valueOf(n.getId()), sep)).append(sep) + .append(csv(n.getLabel() != null ? n.getLabel() : "", sep)); + for (Column col : gm.getNodeTable()) { + if (!col.isProperty()) { + Object v = n.getAttribute(col); + sb.append(sep).append(csv(v != null ? v.toString() : "", sep)); + } + } + sb.append("\n"); + } + } finally { g.readUnlock(); } + } + + if ("edges".equalsIgnoreCase(target) || "both".equalsIgnoreCase(target)) { + if (sb.length() > 0) sb.append("\n"); + sb.append(csv("Source", sep)).append(sep).append(csv("Target", sep)).append(sep).append(csv("Weight", sep)); + for (Column col : gm.getEdgeTable()) { + if (!col.isProperty()) sb.append(sep).append(csv(col.getTitle(), sep)); + } + sb.append("\n"); + g.readLock(); + try { + for (Edge e : g.getEdges()) { + sb.append(csv(String.valueOf(e.getSource().getId()), sep)).append(sep) + .append(csv(String.valueOf(e.getTarget().getId()), sep)).append(sep) + .append(csv(String.valueOf(e.getWeight()), sep)); + for (Column col : gm.getEdgeTable()) { + if (!col.isProperty()) { + Object v = e.getAttribute(col); + sb.append(sep).append(csv(v != null ? v.toString() : "", sep)); + } + } + sb.append("\n"); + } + } finally { g.readUnlock(); } + } + } + return sb.toString(); + } + + /** + * RFC 4180 field quoting: wrap the value in double quotes (doubling any internal + * quote) when it contains the separator, a quote, or a line break. Without this, + * a label or attribute containing the separator silently corrupts the columns. + */ + static String csv(String value, String sep) { + if (value == null) value = ""; + boolean needsQuote = value.contains(sep) || value.contains("\"") + || value.contains("\n") || value.contains("\r"); + return needsQuote ? "\"" + value.replace("\"", "\"\"") + "\"" : value; + } + + // ─── Import ────────────────────────────────────────────────────── + + public JsonObject importFile(String filePath) { + return runOnEDT(() -> { + File file = new File(filePath); + if (!file.exists()) return error("File not found: " + filePath); + try { + ImportController ic = Lookup.getDefault().lookup(ImportController.class); + Container c = ic.importFile(file); + if (c == null) return error("Import failed - unsupported format or empty file"); + + Workspace ws = currentWorkspace(); + if (ws == null) { + getProjectController().newProject(); + ws = currentWorkspace(); + } + + Processor processor = null; + for (Processor p : Lookup.getDefault().lookupAll(Processor.class)) { + if (p.getClass().getSimpleName().equals("DefaultProcessor")) { + processor = p; + break; + } + } + if (processor == null) processor = Lookup.getDefault().lookup(Processor.class); + if (processor == null) return error("No processor found"); + + Workspace importedWs = ic.process(c, processor, ws); + + // Cap imported node sizes to prevent viz:size from GEXF making nodes enormous + Graph importedGraph = getGraphController().getGraphModel(ws).getGraph(); + Node[] importedNodes = importedGraph.getNodes().toArray(); + for (Node n : importedNodes) { + if (n.size() > 30.0f) n.setSize(30.0f); + } + + Workspace effectiveWs = importedWs != null ? importedWs : ws; + Graph g = getGraphController().getGraphModel(effectiveWs).getGraph(); + JsonObject r = success("Imported from " + file.getName()); + r.addProperty("node_count", g.getNodeCount()); + r.addProperty("edge_count", g.getEdgeCount()); + return r; + } catch (Exception e) { return error("Import failed: " + e.getMessage()); } + }); + } + + // ─── Graph Operations ──────────────────────────────────────────── + + public JsonObject clearGraph() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + int nodeCount = g.getNodeCount(); + int edgeCount = g.getEdgeCount(); + g.clear(); + JsonObject r = success("Graph cleared"); + r.addProperty("nodes_removed", nodeCount); + r.addProperty("edges_removed", edgeCount); + return r; + } finally { g.writeUnlock(); } + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject removeIsolates() { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph g = currentGraphModel().getGraph(); + java.util.List isolates = new java.util.ArrayList<>(); + lockWrite(g); + try { + for (Node n : g.getNodes().toArray()) { + if (g.getDegree(n) == 0) isolates.add(n); + } + for (Node n : isolates) g.removeNode(n); + } finally { g.writeUnlock(); } + // Refresh preview so exports reflect the filtered graph (outside the lock) + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + if (pc != null) pc.refreshPreview(ws); + JsonObject r = success("Removed " + isolates.size() + " isolated nodes"); + r.addProperty("removed", isolates.size()); + r.addProperty("remaining_nodes", g.getNodeCount()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject extractEgoNetwork(String nodeId, int depth) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + Graph g = currentGraphModel().getGraph(); + Node center = g.getNode(nodeId); + if (center == null) return error("Node not found: " + nodeId); + + // BFS to find nodes within depth + java.util.Set keep = new java.util.LinkedHashSet<>(); + java.util.Queue queue = new java.util.LinkedList<>(); + java.util.Map distances = new java.util.HashMap<>(); + keep.add(center); + queue.add(center); + distances.put(center, 0); + + while (!queue.isEmpty()) { + Node current = queue.poll(); + int dist = distances.get(current); + if (dist >= depth) continue; + for (Node neighbor : g.getNeighbors(current)) { + if (!keep.contains(neighbor)) { + keep.add(neighbor); + queue.add(neighbor); + distances.put(neighbor, dist + 1); + } + } + } + + // Remove nodes not in keep set + java.util.List toRemove = new java.util.ArrayList<>(); + lockWrite(g); + try { + for (Node n : g.getNodes().toArray()) { + if (!keep.contains(n)) toRemove.add(n); + } + for (Node n : toRemove) g.removeNode(n); + } finally { g.writeUnlock(); } + + // Refresh preview so exports reflect the filtered graph (outside the lock) + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + if (pc != null) pc.refreshPreview(ws); + + JsonObject r = success("Ego network extracted for " + nodeId); + r.addProperty("kept_nodes", keep.size()); + r.addProperty("removed_nodes", toRemove.size()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + public JsonObject extractGiantComponent() { + // Statistics must run OFF the EDT (they dispatch UI work to EDT internally). + // Only node removal and preview refresh need the EDT. + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + + // Run connected components (on HTTP thread, not EDT) + StatisticsBuilder ccBuilder = null; + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + if (sb.getName().equalsIgnoreCase("ConnectedComponents") || + sb.getClass().getSimpleName().toLowerCase().contains("connectedcomponents")) { + ccBuilder = sb; + break; + } + } + if (ccBuilder == null) return error("ConnectedComponents statistic not found"); + + Statistics stat = ccBuilder.getStatistics(); + stat.execute(gm); + + // Find the column + Column ccCol = gm.getNodeTable().getColumn("componentnumber"); + if (ccCol == null) { + for (Column col : gm.getNodeTable()) { + if (col.getTitle().toLowerCase().contains("component")) { + ccCol = col; + break; + } + } + } + if (ccCol == null) return error("Component column not found after running statistics"); + + // Count nodes per component + java.util.Map componentSizes = new java.util.HashMap<>(); + Node[] allNodes = g.getNodes().toArray(); + final Column fccCol = ccCol; + for (Node n : allNodes) { + Object v = n.getAttribute(fccCol); + int comp = v instanceof Number ? ((Number) v).intValue() : 0; + componentSizes.put(comp, componentSizes.getOrDefault(comp, 0) + 1); + } + + int giantComp = 0; + int giantSize = 0; + for (java.util.Map.Entry e : componentSizes.entrySet()) { + if (e.getValue() > giantSize) { + giantSize = e.getValue(); + giantComp = e.getKey(); + } + } + + // Remove nodes on EDT (graph modification + preview refresh) + final int gc = giantComp; + final int gs = giantSize; + final int compCount = componentSizes.size(); + return runOnEDT(() -> { + try { + java.util.List toRemove = new java.util.ArrayList<>(); + for (Node n : allNodes) { + Object v = n.getAttribute(fccCol); + int comp = v instanceof Number ? ((Number) v).intValue() : -1; + if (comp != gc) toRemove.add(n); + } + lockWrite(g); + try { for (Node n : toRemove) g.removeNode(n); } + finally { g.writeUnlock(); } + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + if (pc != null) pc.refreshPreview(ws); + JsonObject r = success("Giant component extracted"); + r.addProperty("kept_nodes", gs); + r.addProperty("removed_nodes", toRemove.size()); + r.addProperty("component_count", compCount); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + public JsonObject setEdgeThicknessByWeight(float minThickness, float maxThickness) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc.getModel(ws); + if (pm == null) return error("Preview model not available"); + + // Set edge thickness to be rescaled based on weight + // Use the preview property for edge thickness + PreviewProperty edgeThicknessProp = pm.getProperties().getProperty("edge.thickness"); + if (edgeThicknessProp != null) { + edgeThicknessProp.setValue(minThickness); + } + + // Set rescale weight property if available + PreviewProperty rescaleProp = pm.getProperties().getProperty("edge.rescale-weight"); + if (rescaleProp != null) { + rescaleProp.setValue(true); + } + + PreviewProperty rescaleMinProp = pm.getProperties().getProperty("edge.rescale-weight.min"); + if (rescaleMinProp != null) { + rescaleMinProp.setValue(minThickness); + } + + PreviewProperty rescaleMaxProp = pm.getProperties().getProperty("edge.rescale-weight.max"); + if (rescaleMaxProp != null) { + rescaleMaxProp.setValue(maxThickness); + } + + JsonObject r = success("Edge thickness configured by weight"); + r.addProperty("min_thickness", minThickness); + r.addProperty("max_thickness", maxThickness); + return r; + } catch (Exception e) { + return error("Failed: " + e.getMessage()); + } + }); + } + + public JsonObject resetFilters() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + // setVisibleView() takes Gephi's own blocking write lock; hold our deadlock-safe + // lock first so that call re-enters instead of queuing behind the renderer. + lockWrite(g); + try { + gm.setVisibleView(null); + } finally { g.writeUnlock(); } + return success("Filters reset - full graph view restored"); + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + } + + // ─── Shutdown ──────────────────────────────────────────────────── + + public void shutdown() { + layoutRunning.set(false); + layoutExecutor.shutdownNow(); + } +} diff --git a/modules/GephiMcp/src/main/nbm/manifest.mf b/modules/GephiMcp/src/main/nbm/manifest.mf new file mode 100644 index 000000000..5b5387fd1 --- /dev/null +++ b/modules/GephiMcp/src/main/nbm/manifest.mf @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Install: org/gephi/plugins/mcp/Installer.class +OpenIDE-Module-Localizing-Bundle: org/gephi/plugins/mcp/Bundle.properties diff --git a/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties b/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties new file mode 100644 index 000000000..3a1eb550e --- /dev/null +++ b/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties @@ -0,0 +1,4 @@ +OpenIDE-Module-Name=Gephi MCP Plugin +OpenIDE-Module-Display-Category=Tool +OpenIDE-Module-Short-Description=HTTP API for remote Gephi control via MCP +OpenIDE-Module-Long-Description=Exposes a REST API on localhost:8080 for controlling Gephi Desktop through the Model Context Protocol. Enables LLMs like Claude to create projects, manipulate graphs, run layouts, compute statistics, and export data.\n\nDeveloped by Matt Artz (https://www.mattartz.me | ORCID: https://orcid.org/0000-0002-3822-1429)\nSource: https://github.com/MattArtzAnthro diff --git a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java new file mode 100644 index 000000000..632ecfc5d --- /dev/null +++ b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java @@ -0,0 +1,39 @@ +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Unit tests for the DNS-rebinding Host-header guard. */ +class HostHeaderTest { + + @Test + void loopbackHostsAreAccepted() { + assertTrue(GephiAPIServer.isLoopbackHost("127.0.0.1:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("127.0.0.1")); + assertTrue(GephiAPIServer.isLoopbackHost("localhost:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("localhost")); + assertTrue(GephiAPIServer.isLoopbackHost("LOCALHOST:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("[::1]:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("[::1]")); + } + + @Test + void missingHostHeaderIsAllowed() { + // Non-browser clients (e.g. the MCP server) may omit Host; browsers never do, + // so this does not open a browser bypass. + assertTrue(GephiAPIServer.isLoopbackHost(null)); + assertTrue(GephiAPIServer.isLoopbackHost("")); + } + + @Test + void rebindingAndRemoteHostsAreRejected() { + assertFalse(GephiAPIServer.isLoopbackHost("evil.com")); + assertFalse(GephiAPIServer.isLoopbackHost("evil.com:8080")); + assertFalse(GephiAPIServer.isLoopbackHost("attacker.localhost.evil.com")); + assertFalse(GephiAPIServer.isLoopbackHost("127.0.0.1.evil.com")); + assertFalse(GephiAPIServer.isLoopbackHost("192.168.1.5:8080")); + assertFalse(GephiAPIServer.isLoopbackHost("0.0.0.0:8080")); + } +} diff --git a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java new file mode 100644 index 000000000..52f455601 --- /dev/null +++ b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java @@ -0,0 +1,190 @@ +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for the graph-mutation cores against a standalone in-memory + * GraphModel (no NetBeans platform / running Gephi required). These exercise the + * actual fixes: batch attributes, edge directedness, the negative-value ranking + * regression, and CSV assembly. + */ +class GraphOpsTest { + + private static GraphModel newModel() { + return GraphModel.Factory.newInstance(); + } + + /** Build a node map {id, attributes:{...}} from id + alternating attr key/value pairs. */ + private static Map node(String id, Object... attrKv) { + Map m = new LinkedHashMap<>(); + m.put("id", id); + if (attrKv.length > 0) { + Map attrs = new LinkedHashMap<>(); + for (int i = 0; i + 1 < attrKv.length; i += 2) attrs.put((String) attrKv[i], attrKv[i + 1]); + m.put("attributes", attrs); + } + return m; + } + + @Test + void batchAddAppliesPerNodeAttributes() { + GraphModel gm = newModel(); + JsonObject r = GephiControlService.addNodesToModel(gm, + List.of(node("a", "team", "red"), node("b", "team", "blue"))); + assertTrue(r.get("success").getAsBoolean()); + assertEquals(2, r.get("added").getAsInt()); + + Graph g = gm.getGraph(); + Column team = gm.getNodeTable().getColumn("team"); + assertNotNull(team, "attribute column should be auto-created"); + assertEquals("red", g.getNode("a").getAttribute(team)); + assertEquals("blue", g.getNode("b").getAttribute(team)); + } + + @Test + void batchAddSkipsDuplicateIds() { + GraphModel gm = newModel(); + GephiControlService.addNodeToModel(gm, "a", null, null); + JsonObject r = GephiControlService.addNodesToModel(gm, List.of(node("a"), node("b"))); + assertEquals(1, r.get("added").getAsInt()); + assertEquals(1, r.get("skipped").getAsInt()); + } + + @Test + void addEdgeRespectsUndirectedFlag() { + GraphModel gm = newModel(); + GephiControlService.addNodeToModel(gm, "a", null, null); + GephiControlService.addNodeToModel(gm, "b", null, null); + JsonObject r = GephiControlService.addEdgeToModel(gm, "a", "b", 2.0, false); + assertTrue(r.get("success").getAsBoolean()); + + Graph g = gm.getGraph(); + Edge e = g.getEdge(g.getNode("a"), g.getNode("b"), 0); // type 0 == undirected + assertNotNull(e, "undirected edge (type 0) should exist"); + assertFalse(e.isDirected()); + assertEquals(2.0, e.getWeight(), 1e-9); + } + + @Test + void addEdgeRejectsDuplicate() { + GraphModel gm = newModel(); + GephiControlService.addNodeToModel(gm, "a", null, null); + GephiControlService.addNodeToModel(gm, "b", null, null); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true).get("success").getAsBoolean()); + assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true).get("success").getAsBoolean()); + } + + @Test + void batchAddEdgesHonorsDirectedLabelAndAttributes() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of(node("a"), node("b"))); + + Map edge = new LinkedHashMap<>(); + edge.put("source", "a"); + edge.put("target", "b"); + edge.put("directed", false); + edge.put("label", "knows"); + Map attrs = new LinkedHashMap<>(); + attrs.put("since", 1999); + edge.put("attributes", attrs); + + JsonObject r = GephiControlService.addEdgesToModel(gm, List.of(edge)); + assertEquals(1, r.get("added").getAsInt()); + + Graph g = gm.getGraph(); + Edge e = GephiControlService.findEdge(g, g.getNode("a"), g.getNode("b")); + assertNotNull(e); + assertFalse(e.isDirected()); + assertEquals("knows", e.getLabel()); + Column since = gm.getEdgeTable().getColumn("since"); + assertNotNull(since); + assertEquals(1999, ((Number) e.getAttribute(since)).intValue()); + } + + @Test + void numericRangeHandlesAllNegativeValues() { + // The regression that motivated the fix: a column whose values are all negative. + // The old Double.MIN_VALUE seed left max at a tiny positive number here. + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, + List.of(node("a", "score", -10.0), node("b", "score", -2.0), node("c", "score", -7.0))); + Column score = gm.getNodeTable().getColumn("score"); + double[] mm = GephiControlService.numericRange(gm.getGraph(), score); + assertNotNull(mm); + assertEquals(-10.0, mm[0], 1e-9, "min"); + assertEquals(-2.0, mm[1], 1e-9, "max"); + } + + @Test + void numericRangeIsNullWhenNoNumericValues() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of(node("a", "tag", "x"))); + Column tag = gm.getNodeTable().getColumn("tag"); + assertNull(GephiControlService.numericRange(gm.getGraph(), tag)); + } + + @Test + void addColumnCreatesAndRejectsDuplicateAndBadType() { + GraphModel gm = newModel(); + assertTrue(GephiControlService.addColumnToModel(gm, "weight2", "double", "node") + .get("success").getAsBoolean()); + assertNotNull(gm.getNodeTable().getColumn("weight2")); + // duplicate name -> error + assertFalse(GephiControlService.addColumnToModel(gm, "weight2", "double", "node") + .get("success").getAsBoolean()); + // unknown type -> error + assertFalse(GephiControlService.addColumnToModel(gm, "other", "notatype", "node") + .get("success").getAsBoolean()); + } + + // ── the deadlock-safe write lock (reflection linchpin) ────────────── + + @Test + void writeLockHandleResolvesGephiInternalLock() { + // If Gephi ever renames GraphLockImpl.writeLock, this returns null and lockWrite + // silently degrades to the deadlocking blocking lock. This test guards that. + GraphModel gm = newModel(); + assertNotNull(GephiControlService.writeLockHandle(gm.getGraph()), + "reflection into the graph's WriteLock must resolve"); + } + + @Test + void lockWriteAcquiresAndReleasesViaWriteUnlock() { + GraphModel gm = newModel(); + Graph g = gm.getGraph(); + GephiControlService.lockWrite(g); + try { + assertEquals(1, g.getLock().getWriteHoldCount(), "lockWrite must hold the write lock"); + } finally { + g.writeUnlock(); + } + assertEquals(0, g.getLock().getWriteHoldCount(), "writeUnlock must release what lockWrite took"); + } + + @Test + void buildCsvQuotesFieldsContainingSeparator() { + GraphModel gm = newModel(); + Map n = new LinkedHashMap<>(); + n.put("id", "a"); + n.put("label", "Smith, John"); // label contains the separator -> must be quoted + GephiControlService.addNodesToModel(gm, List.of(n)); + + String[] lines = GephiControlService.buildCsv(gm, ",", "nodes").split("\n"); + assertEquals("Id,Label", lines[0]); + assertEquals("a,\"Smith, John\"", lines[1]); + } +} diff --git a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java new file mode 100644 index 000000000..a3d6e5e67 --- /dev/null +++ b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java @@ -0,0 +1,128 @@ +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the pure helpers in GephiControlService — CSV quoting, type-string + * resolution, and value coercion. These need no Gephi runtime. + */ +class HelpersTest { + + // ── CSV (RFC 4180) escaping ────────────────────────────────────────── + + @Test + void csvLeavesPlainValuesUnquoted() { + assertEquals("hello", GephiControlService.csv("hello", ",")); + assertEquals("123", GephiControlService.csv("123", ",")); + } + + @Test + void csvQuotesValuesContainingSeparator() { + assertEquals("\"a,b\"", GephiControlService.csv("a,b", ",")); + } + + @Test + void csvDoublesInternalQuotes() { + assertEquals("\"she said \"\"hi\"\"\"", GephiControlService.csv("she said \"hi\"", ",")); + } + + @Test + void csvQuotesNewlines() { + assertEquals("\"line1\nline2\"", GephiControlService.csv("line1\nline2", ",")); + } + + @Test + void csvRespectsCustomSeparator() { + // a ';' is safe under a ',' separator but must be quoted under a ';' separator + assertEquals("a;b", GephiControlService.csv("a;b", ",")); + assertEquals("\"a;b\"", GephiControlService.csv("a;b", ";")); + } + + @Test + void csvHandlesNull() { + assertEquals("", GephiControlService.csv(null, ",")); + } + + // ── type string -> class ───────────────────────────────────────────── + + @Test + void typeStringToClassKnownTypes() { + assertEquals(String.class, GephiControlService.typeStringToClass("string")); + assertEquals(Integer.class, GephiControlService.typeStringToClass("INT")); + assertEquals(Integer.class, GephiControlService.typeStringToClass("integer")); + assertEquals(Double.class, GephiControlService.typeStringToClass("double")); + assertEquals(Boolean.class, GephiControlService.typeStringToClass("bool")); + assertEquals(Long.class, GephiControlService.typeStringToClass("long")); + } + + @Test + void typeStringToClassUnknownIsNull() { + assertNull(GephiControlService.typeStringToClass("nope")); + assertNull(GephiControlService.typeStringToClass(null)); + } + + // ── value coercion to a column's type ──────────────────────────────── + + @Test + void convertToColumnTypeParsesNumbers() { + assertEquals(7, GephiControlService.convertToColumnType("7.9", Integer.class)); // truncates + assertEquals(3.5, GephiControlService.convertToColumnType("3.5", Double.class)); + assertEquals(true, GephiControlService.convertToColumnType("true", Boolean.class)); + } + + @Test + void convertToColumnTypePassesThroughMatchingType() { + assertEquals(42, GephiControlService.convertToColumnType(42, Integer.class)); + } + + @Test + void convertToColumnTypeFallsBackToStringOnGarbage() { + assertEquals("abc", GephiControlService.convertToColumnType("abc", Integer.class)); + } + + // ── layout property coercion (e.g. "100.0" -> int 100) ─────────────── + + @Test + void convertLayoutPropertyHandlesNumericStrings() { + assertEquals(100, GephiControlService.convertLayoutProperty("100.0", int.class)); + assertEquals(2.5, GephiControlService.convertLayoutProperty("2.5", double.class)); + assertEquals(true, GephiControlService.convertLayoutProperty("true", boolean.class)); + assertEquals(1.5f, GephiControlService.convertLayoutProperty("1.5", float.class)); + } + + @Test + void convertLayoutPropertyReturnsNullOnGarbage() { + assertNull(GephiControlService.convertLayoutProperty("xyz", int.class)); + } + + // ── layout name matching (real Gephi builder names) ────────────────── + + private static final List LAYOUTS = List.of( + "Yifan Hu", "Yifan Hu Proportional", "Force Atlas", "ForceAtlas 2", + "Fruchterman Reingold", "Label Adjust", "Noverlap", "OpenOrd", "Random Layout"); + + @Test + void layoutMatchFoldsSpacesForDocumentedShortNames() { + // The names the skill/docs use must resolve to the real builders. + assertEquals("ForceAtlas 2", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "forceatlas2"))); + assertEquals("Yifan Hu", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "yifanhu"))); + assertEquals("Fruchterman Reingold", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "fruchterman"))); + } + + @Test + void layoutMatchPrefersExactOverSubstring() { + // "Force Atlas" must not be hijacked by "ForceAtlas 2" (and vice-versa). + assertEquals("Force Atlas", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "Force Atlas"))); + assertEquals("ForceAtlas 2", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "ForceAtlas 2"))); + } + + @Test + void layoutMatchReturnsMinusOneWhenNoMatch() { + assertEquals(-1, GephiControlService.bestLayoutMatch(LAYOUTS, "nonexistent")); + assertEquals(-1, GephiControlService.bestLayoutMatch(LAYOUTS, null)); + } +} diff --git a/pom.xml b/pom.xml index 64a951086..74d260bb4 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ + modules/GephiMcp From 095c1119592c3de985a70ccfe7d727091ef040bb Mon Sep 17 00:00:00 2001 From: Matt Artz Date: Thu, 2 Jul 2026 14:48:42 +0200 Subject: [PATCH 2/6] Naming update to the plugin --- modules/GephiMcp/pom.xml | 2 +- .../main/resources/org/gephi/plugins/mcp/Bundle.properties | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml index 5502a986b..5ff884de3 100644 --- a/modules/GephiMcp/pom.xml +++ b/modules/GephiMcp/pom.xml @@ -15,7 +15,7 @@ 1.1.3 nbm - Gephi MCP Plugin + Gephi AI (MCP) HTTP API for remote Gephi control via MCP (Model Context Protocol) https://github.com/MattArtzAnthro/gephi-ai diff --git a/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties b/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties index 3a1eb550e..572cad87a 100644 --- a/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties +++ b/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties @@ -1,4 +1,4 @@ -OpenIDE-Module-Name=Gephi MCP Plugin +OpenIDE-Module-Name=Gephi AI (MCP) OpenIDE-Module-Display-Category=Tool -OpenIDE-Module-Short-Description=HTTP API for remote Gephi control via MCP +OpenIDE-Module-Short-Description=Control Gephi with AI assistants like Claude via the Model Context Protocol. OpenIDE-Module-Long-Description=Exposes a REST API on localhost:8080 for controlling Gephi Desktop through the Model Context Protocol. Enables LLMs like Claude to create projects, manipulate graphs, run layouts, compute statistics, and export data.\n\nDeveloped by Matt Artz (https://www.mattartz.me | ORCID: https://orcid.org/0000-0002-3822-1429)\nSource: https://github.com/MattArtzAnthro From 03e9e214b5654206a291510b89cf68d8b9ce1388 Mon Sep 17 00:00:00 2001 From: Matt Artz Date: Fri, 3 Jul 2026 16:27:38 -0400 Subject: [PATCH 3/6] gephi-ai mcp update --- modules/GephiMcp/pom.xml | 6 +- .../gephi/plugins/mcp/api/GephiAPIServer.java | 33 +- .../mcp/service/GephiControlService.java | 381 +++++++++++++++--- .../plugins/mcp/service/RenderPause.java | 83 ++++ .../plugins/mcp/service/GraphOpsTest.java | 40 ++ 5 files changed, 481 insertions(+), 62 deletions(-) create mode 100644 modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml index 5ff884de3..9eba00a9a 100644 --- a/modules/GephiMcp/pom.xml +++ b/modules/GephiMcp/pom.xml @@ -12,7 +12,7 @@ org.gephi.plugins gephi-mcp - 1.1.3 + 1.2.1 nbm Gephi AI (MCP) @@ -72,6 +72,10 @@ org.gephi filters-api + + org.gephi + visualization-api + diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java index b7fa65646..1a4eb2473 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -104,11 +104,36 @@ private JsonObject routeRequest(String uri, Method method, Map p JsonObject result = new JsonObject(); result.addProperty("success", true); result.addProperty("service", "Gephi MCP API"); - result.addProperty("version", "1.1.3"); + result.addProperty("version", "1.2.1"); result.addProperty("status", "running"); + // "busy" here (persistently) means Gephi is wedged and needs a restart. + result.addProperty("graph_lock", service.graphLockProbe()); + result.add("graph_lock_stats", service.graphLockStats()); return result; } + // ─── View / camera (teaching mode) ─────────────────────────── + + if ("/view/focus".equals(uri) && Method.POST.equals(method)) { + String mode = body != null && body.has("mode") ? body.get("mode").getAsString() : "graph"; + String id = body != null && body.has("id") ? body.get("id").getAsString() : null; + String source = body != null && body.has("source") ? body.get("source").getAsString() : null; + String target = body != null && body.has("target") ? body.get("target").getAsString() : null; + Double x = body != null && body.has("x") ? body.get("x").getAsDouble() : null; + Double y = body != null && body.has("y") ? body.get("y").getAsDouble() : null; + Double w = body != null && body.has("w") ? body.get("w").getAsDouble() : null; + Double h = body != null && body.has("h") ? body.get("h").getAsDouble() : null; + Double zoom = body != null && body.has("zoom") ? body.get("zoom").getAsDouble() : null; + java.util.List select = null; + if (body != null && body.has("select")) { + select = new java.util.ArrayList<>(); + for (com.google.gson.JsonElement el : body.get("select").getAsJsonArray()) { + select.add(el.getAsString()); + } + } + return service.focusView(mode, id, source, target, x, y, w, h, zoom, select); + } + // ─── Project ───────────────────────────────────────────────── if ("/project/new".equals(uri) && Method.POST.equals(method)) { @@ -539,7 +564,11 @@ private JsonObject routeRequest(String uri, Method method, Map p // ─── Export ────────────────────────────────────────────────── if ("/export/gexf".equals(uri) && Method.POST.equals(method)) { - if (body == null || !body.has("file")) return errorResult("Missing 'file'"); + // no "file" (or inline:true) -> return the GEXF as a string in "content" + if (body == null || !body.has("file") + || (body.has("inline") && body.get("inline").getAsBoolean())) { + return service.exportGexfContent(); + } return service.exportGexf(body.get("file").getAsString()); } diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java index 2b594f3d9..34e92e1d0 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -87,14 +87,26 @@ private T runOnEDT(Callable callable) { try { return callable.call(); } catch (Exception e) { throw new RuntimeException(e); } } + // Bounded wait: invokeAndWait parks forever when the EDT is wedged (the + // "health answers but nothing else does" symptom). Fail fast with guidance + // instead of hanging until the client's timeout. final Object[] result = new Object[1]; final Exception[] exception = new Exception[1]; + final java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + SwingUtilities.invokeLater(() -> { + try { result[0] = callable.call(); } + catch (Exception e) { exception[0] = e; } + finally { done.countDown(); } + }); try { - SwingUtilities.invokeAndWait(() -> { - try { result[0] = callable.call(); } - catch (Exception e) { exception[0] = e; } - }); - } catch (Exception e) { throw new RuntimeException(e); } + if (!done.await(15, java.util.concurrent.TimeUnit.SECONDS)) { + throw new RuntimeException( + "Gephi's UI thread is unresponsive — the app is likely wedged; fully quit and reopen Gephi"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for Gephi's UI thread"); + } if (exception[0] != null) throw new RuntimeException(exception[0]); return (T) result[0]; } @@ -143,18 +155,87 @@ private GraphModel currentGraphModel() { * callers turn into a "graph busy" error instead of hanging forever. */ static void lockWrite(Graph g) { - java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock wl = writeLockHandle(g); - if (wl == null) { g.writeLock(); return; } - long deadline = System.nanoTime() + 15_000_000_000L; + RenderPause.pause(); // free the renderer's read-lock pressure for this section + boolean acquired = false; try { + java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock wl = writeLockHandle(g); + if (wl == null) { g.writeLock(); acquired = true; return; } + long deadline = System.nanoTime() + 15_000_000_000L; while (!wl.tryLock(120, java.util.concurrent.TimeUnit.MILLISECONDS)) { if (System.nanoTime() > deadline) throw new RuntimeException("Graph is busy (renderer holds the lock); please retry"); Thread.sleep(5); } + acquired = true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Interrupted while acquiring the write lock"); + } finally { + if (!acquired) RenderPause.resume(); + } + } + + /** Release the write lock and resume the renderer paused by lockWrite. */ + static void unlockWrite(Graph g) { + try { + g.writeUnlock(); + } finally { + RenderPause.resume(); + } + } + + private static volatile java.lang.reflect.Field READ_LOCK_FIELD; + + /* + * ITERATION RULE (wedge prevention): never iterate a live NodeIterable / + * EdgeIterable directly — always iterate .toArray(). A live iterator + * auto-acquires the graph read lock in its constructor and releases it only + * on exhaustion or doBreak(); an early break, return, or exception leaks the + * hold, and because NanoHTTPD threads die after their request, the leak is + * permanent and wedges every future write (found the hard way; see + * GraphOpsTest#earlyBreakOverToArraySnapshotLeavesNoReadHold). + */ + + /** + * Timed read-lock acquisition. Plain readLock() parks unboundedly in the lock's + * wait queue; when a writer is already parked (Gephi's own blocking writeLock()) + * every new reader queues behind it and the request hangs until the client's + * timeout — the chronic "health answers but nothing else does" symptom. A timed + * tryLock turns that into an immediate, actionable error instead. + */ + static void lockRead(Graph g) { + java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock rl = readLockHandle(g); + if (rl == null) { g.readLock(); return; } + long deadline = System.nanoTime() + 10_000_000_000L; + try { + while (!rl.tryLock(120, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (System.nanoTime() > deadline) + throw new RuntimeException( + "Graph is busy (lock unavailable) — if this persists, Gephi is wedged; fully quit and reopen it"); + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while acquiring the read lock"); + } + } + + /** The underlying ReentrantReadWriteLock.ReadLock behind Graph.getLock(), or null if unreachable. */ + static java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock readLockHandle(Graph g) { + try { + org.gephi.graph.api.GraphLock lock = g.getLock(); + if (lock == null) return null; + java.lang.reflect.Field f = READ_LOCK_FIELD; + if (f == null || !f.getDeclaringClass().isInstance(lock)) { + f = lock.getClass().getDeclaredField("readLock"); + f.setAccessible(true); + READ_LOCK_FIELD = f; + } + Object v = f.get(lock); + return (v instanceof java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock) + ? (java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock) v : null; + } catch (Throwable t) { + return null; } } @@ -421,7 +502,7 @@ static JsonObject addNodeToModel(GraphModel gm, String id, String label, Map> nodes r.addProperty("added", added); r.addProperty("skipped", skipped); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -481,7 +562,7 @@ public JsonObject removeNode(String id) { JsonObject r = success("Node removed"); r.addProperty("edges_removed", edgesRemoved); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -504,7 +585,7 @@ public JsonObject bulkRemoveNodes(List ids) { r.addProperty("removed", removed); r.addProperty("not_found", notFound); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -514,11 +595,13 @@ public JsonObject queryNodes(String attr, String val, int limit, int offset) { if (ws == null) return error("No project open"); GraphModel gm = getGraphController().getGraphModel(ws); Graph g = gm.getGraph(); - g.readLock(); + lockRead(g); try { JsonArray arr = new JsonArray(); int count = 0, skip = 0; - for (Node n : g.getNodes()) { + // toArray, not the live iterable: breaking out of an auto-locked + // iterator before exhaustion leaks its read hold permanently. + for (Node n : g.getNodes().toArray()) { if (skip++ < offset) continue; if (count >= limit) break; JsonObject o = new JsonObject(); @@ -605,7 +688,7 @@ public JsonObject setNodeLabel(String id, String label) { if (n == null) return error("Node not found: " + id); n.setLabel(label); return success("Label set"); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -621,7 +704,7 @@ public JsonObject setNodePosition(String id, float x, float y) { n.setX(x); n.setY(y); return success("Position set"); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -646,7 +729,7 @@ public JsonObject batchSetPositions(List> positions) { r.addProperty("set", set); r.addProperty("not_found", notFound); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -671,7 +754,7 @@ static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double w Edge e = gm.factory().newEdge(s, t, directed ? 1 : 0, weight != null ? weight : 1.0, directed); g.addEdge(e); return success("Edge added"); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -715,7 +798,7 @@ static JsonObject addEdgesToModel(GraphModel gm, List> edges r.addProperty("added", added); r.addProperty("skipped", skipped); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -732,7 +815,7 @@ public JsonObject removeEdge(String source, String target) { if (e == null) return error("Edge not found"); g.removeEdge(e); return success("Edge removed"); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -749,7 +832,7 @@ public JsonObject setEdgeWeight(String source, String target, double weight) { if (e == null) return error("Edge not found"); e.setWeight(weight); return success("Weight set to " + weight); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -766,7 +849,7 @@ public JsonObject setEdgeLabel(String source, String target, String label) { if (e == null) return error("Edge not found"); e.setLabel(label); return success("Edge label set"); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -776,11 +859,13 @@ public JsonObject queryEdges(int limit, int offset) { if (ws == null) return error("No project open"); GraphModel gm = getGraphController().getGraphModel(ws); Graph g = gm.getGraph(); - g.readLock(); + lockRead(g); try { JsonArray arr = new JsonArray(); int count = 0, skip = 0; - for (Edge e : g.getEdges()) { + // toArray, not the live iterable: breaking out of an auto-locked + // iterator before exhaustion leaks its read hold permanently. + for (Edge e : g.getEdges().toArray()) { if (skip++ < offset) continue; if (count >= limit) break; JsonObject o = new JsonObject(); @@ -828,7 +913,7 @@ public JsonObject getGraphStats() { if (ws == null) return error("No project open"); GraphModel gm = getGraphController().getGraphModel(ws); Graph g = gm.getGraph(); - g.readLock(); + lockRead(g); try { int nc = g.getNodeCount(), ec = g.getEdgeCount(); double density = nc > 1 ? (2.0 * ec) / (nc * (nc - 1)) : 0; @@ -908,7 +993,7 @@ static JsonObject addColumnToModel(GraphModel gm, String name, String type, Stri try { if (table.getColumn(name) != null) return error("Column already exists: " + name); table.addColumn(name, cls); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } return success("Column '" + name + "' added"); } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -927,7 +1012,7 @@ public JsonObject setNodeAttributes(String id, Map attrs) { ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); } return success("Attributes set on node " + id); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -958,7 +1043,7 @@ public JsonObject batchSetNodeAttributes(List> updates) { r.addProperty("set", set); r.addProperty("not_found", notFound); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -978,7 +1063,7 @@ public JsonObject setEdgeAttributes(String source, String target, Map> nodeColors) { res.addProperty("set", set); res.addProperty("not_found", notFound); return res; - } finally { graph.writeUnlock(); } + } finally { unlockWrite(graph); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -1117,11 +1202,11 @@ public JsonObject resetAppearance(int r, int g, int b, float size) { Color defaultColor = new Color(r, g, b); lockWrite(graph); try { - for (Node n : graph.getNodes()) { + for (Node n : graph.getNodes().toArray()) { n.setColor(defaultColor); n.setSize(size); } - } finally { graph.writeUnlock(); } + } finally { unlockWrite(graph); } return success("Appearance reset for all nodes"); } catch (Exception e) { return error("Failed: " + e.getMessage()); } }); @@ -1171,7 +1256,7 @@ public JsonObject colorByPartition(String columnName, Map colorMa int colored = 0; lockWrite(graph); try { - for (Node n : graph.getNodes()) { + for (Node n : graph.getNodes().toArray()) { Object v = n.getAttribute(col); if (v != null) { Color c = palette.get(v.toString()); @@ -1181,7 +1266,7 @@ public JsonObject colorByPartition(String columnName, Map colorMa } } } - } finally { graph.writeUnlock(); } + } finally { unlockWrite(graph); } JsonObject r = success("Colored " + colored + " nodes by " + columnName); r.addProperty("partitions", palette.size()); return r; @@ -1197,9 +1282,9 @@ public JsonObject colorByPartition(String columnName, Map colorMa */ static double[] numericRange(Graph g, Column col) { double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; - g.readLock(); + lockRead(g); try { - for (Node n : g.getNodes()) { + for (Node n : g.getNodes().toArray()) { Object v = n.getAttribute(col); if (v instanceof Number) { double d = ((Number) v).doubleValue(); @@ -1211,6 +1296,29 @@ static double[] numericRange(Graph g, Column col) { return min == Double.POSITIVE_INFINITY ? null : new double[]{min, max}; } + /** + * Column lookup for ranking operations. When a degree column is requested + * before the degree statistic has run (the #1 cold-start stumble), computes + * it on the spot instead of failing. + */ + private Column resolveRankingColumn(GraphModel gm, String columnName) { + Column col = gm.getNodeTable().getColumn(columnName); + if (col == null && columnName != null) { + String lc = columnName.toLowerCase(); + if (lc.equals("degree") || lc.equals("indegree") || lc.equals("outdegree")) { + runStatistic("Degree", null); + col = gm.getNodeTable().getColumn(columnName); + } + } + return col; + } + + private static JsonObject columnNotFound(String columnName) { + return error("Column not found: " + columnName + + " — compute the metric first (degree, pagerank, betweenness, modularity" + + " via the statistics tools) or check the columns list"); + } + public JsonObject colorByRanking(String columnName, int rMin, int gMin, int bMin, int rMax, int gMax, int bMax) { return runOnEDT(() -> { Workspace ws = currentWorkspace(); @@ -1218,8 +1326,8 @@ public JsonObject colorByRanking(String columnName, int rMin, int gMin, int bMin try { GraphModel gm = currentGraphModel(); Graph graph = gm.getGraph(); - Column col = gm.getNodeTable().getColumn(columnName); - if (col == null) return error("Column not found: " + columnName); + Column col = resolveRankingColumn(gm, columnName); + if (col == null) return columnNotFound(columnName); double[] mm = numericRange(graph, col); if (mm == null) return error("No numeric values in column " + columnName); @@ -1230,7 +1338,7 @@ public JsonObject colorByRanking(String columnName, int rMin, int gMin, int bMin int colored = 0; lockWrite(graph); try { - for (Node n : graph.getNodes()) { + for (Node n : graph.getNodes().toArray()) { Object v = n.getAttribute(col); if (v instanceof Number) { double t = (((Number) v).doubleValue() - min) / range; @@ -1245,7 +1353,7 @@ public JsonObject colorByRanking(String columnName, int rMin, int gMin, int bMin colored++; } } - } finally { graph.writeUnlock(); } + } finally { unlockWrite(graph); } JsonObject res = success("Colored " + colored + " nodes by ranking on " + columnName); res.addProperty("min_value", min); res.addProperty("max_value", max); @@ -1261,8 +1369,8 @@ public JsonObject sizeByRanking(String columnName, float minSize, float maxSize) try { GraphModel gm = currentGraphModel(); Graph graph = gm.getGraph(); - Column col = gm.getNodeTable().getColumn(columnName); - if (col == null) return error("Column not found: " + columnName); + Column col = resolveRankingColumn(gm, columnName); + if (col == null) return columnNotFound(columnName); double[] mm = numericRange(graph, col); if (mm == null) return error("No numeric values in column " + columnName); @@ -1273,7 +1381,7 @@ public JsonObject sizeByRanking(String columnName, float minSize, float maxSize) int sized = 0; lockWrite(graph); try { - for (Node n : graph.getNodes()) { + for (Node n : graph.getNodes().toArray()) { Object v = n.getAttribute(col); if (v instanceof Number) { double t = (((Number) v).doubleValue() - min) / range; @@ -1281,7 +1389,7 @@ public JsonObject sizeByRanking(String columnName, float minSize, float maxSize) sized++; } } - } finally { graph.writeUnlock(); } + } finally { unlockWrite(graph); } JsonObject res = success("Sized " + sized + " nodes by " + columnName); res.addProperty("min_value", min); res.addProperty("max_value", max); @@ -1626,7 +1734,7 @@ public JsonObject filterByDegreeRange(int minDegree, int maxDegree, boolean dryR } lockWrite(g); try { for (Node n : toRemove) g.removeNode(n); } - finally { g.writeUnlock(); } + finally { unlockWrite(g); } PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); if (pc != null) pc.refreshPreview(ws); JsonObject r = success("Filtered by degree [" + minDegree + ", " + maxDegree + "]"); @@ -1660,7 +1768,7 @@ public JsonObject filterByEdgeWeight(double minWeight, double maxWeight, boolean } lockWrite(g); try { for (Edge e : toRemove) g.removeEdge(e); } - finally { g.writeUnlock(); } + finally { unlockWrite(g); } PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); if (pc != null) pc.refreshPreview(ws); JsonObject r = success("Filtered edges by weight [" + minWeight + ", " + maxWeight + "]"); @@ -1907,6 +2015,28 @@ public JsonObject exportGexf(String filePath) { }); } + /** GEXF export returned inline as a string — no file round-trip. */ + public JsonObject exportGexfContent() { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("gexf"); + if (exporter == null) return error("GEXF exporter not available"); + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(true); + ((GraphExporter) exporter).setWorkspace(ws); + } + java.io.StringWriter sw = new java.io.StringWriter(); + ec.exportWriter(sw, (org.gephi.io.exporter.spi.CharacterExporter) exporter); + JsonObject r = success("GEXF exported inline"); + r.addProperty("content", sw.toString()); + return r; + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + public JsonObject exportPng(String filePath, int w, int h) { return runOnEDT(() -> { Workspace ws = currentWorkspace(); @@ -2048,9 +2178,9 @@ static String buildCsv(GraphModel gm, String separator, String target) { if (!col.isProperty()) sb.append(sep).append(csv(col.getTitle(), sep)); } sb.append("\n"); - g.readLock(); + lockRead(g); try { - for (Node n : g.getNodes()) { + for (Node n : g.getNodes().toArray()) { sb.append(csv(String.valueOf(n.getId()), sep)).append(sep) .append(csv(n.getLabel() != null ? n.getLabel() : "", sep)); for (Column col : gm.getNodeTable()) { @@ -2071,9 +2201,9 @@ static String buildCsv(GraphModel gm, String separator, String target) { if (!col.isProperty()) sb.append(sep).append(csv(col.getTitle(), sep)); } sb.append("\n"); - g.readLock(); + lockRead(g); try { - for (Edge e : g.getEdges()) { + for (Edge e : g.getEdges().toArray()) { sb.append(csv(String.valueOf(e.getSource().getId()), sep)).append(sep) .append(csv(String.valueOf(e.getTarget().getId()), sep)).append(sep) .append(csv(String.valueOf(e.getWeight()), sep)); @@ -2166,7 +2296,7 @@ public JsonObject clearGraph() { r.addProperty("nodes_removed", nodeCount); r.addProperty("edges_removed", edgeCount); return r; - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -2183,7 +2313,7 @@ public JsonObject removeIsolates() { if (g.getDegree(n) == 0) isolates.add(n); } for (Node n : isolates) g.removeNode(n); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } // Refresh preview so exports reflect the filtered graph (outside the lock) PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); if (pc != null) pc.refreshPreview(ws); @@ -2216,7 +2346,7 @@ public JsonObject extractEgoNetwork(String nodeId, int depth) { Node current = queue.poll(); int dist = distances.get(current); if (dist >= depth) continue; - for (Node neighbor : g.getNeighbors(current)) { + for (Node neighbor : g.getNeighbors(current).toArray()) { if (!keep.contains(neighbor)) { keep.add(neighbor); queue.add(neighbor); @@ -2233,7 +2363,7 @@ public JsonObject extractEgoNetwork(String nodeId, int depth) { if (!keep.contains(n)) toRemove.add(n); } for (Node n : toRemove) g.removeNode(n); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } // Refresh preview so exports reflect the filtered graph (outside the lock) PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); @@ -2315,7 +2445,7 @@ public JsonObject extractGiantComponent() { } lockWrite(g); try { for (Node n : toRemove) g.removeNode(n); } - finally { g.writeUnlock(); } + finally { unlockWrite(g); } PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); if (pc != null) pc.refreshPreview(ws); JsonObject r = success("Giant component extracted"); @@ -2381,7 +2511,7 @@ public JsonObject resetFilters() { lockWrite(g); try { gm.setVisibleView(null); - } finally { g.writeUnlock(); } + } finally { unlockWrite(g); } return success("Filters reset - full graph view restored"); } catch (Exception e) { return error("Failed: " + e.getMessage()); } } @@ -2392,4 +2522,137 @@ public void shutdown() { layoutRunning.set(false); layoutExecutor.shutdownNow(); } + + /** + * Cheap wedge detector for /health: try the graph read lock briefly. + * "ok" = acquired instantly; "busy" = could not acquire (a writer is parked or + * the renderer is saturating the lock — if persistent, Gephi needs a restart); + * "none" = no workspace open. + */ + public String graphLockProbe() { + try { + GraphModel gm = currentGraphModel(); + if (gm == null) return "none"; + Graph g = gm.getGraph(); + java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock rl = readLockHandle(g); + if (rl == null) return "unknown"; + if (rl.tryLock(150, java.util.concurrent.TimeUnit.MILLISECONDS)) { + rl.unlock(); + return "ok"; + } + return "busy"; + } catch (Throwable t) { + return "unknown"; + } + } + + /** + * Live counters from the underlying ReentrantReadWriteLock: active read holds, + * write-locked flag, and queued threads. Diagnostic companion to graphLockProbe; + * a nonzero reader count while Gephi is idle means a leaked read hold (the + * precursor of a permanent wedge). All values -1 when unreachable. + */ + public JsonObject graphLockStats() { + JsonObject o = new JsonObject(); + o.addProperty("readers", -1); + o.addProperty("write_locked", false); + o.addProperty("queued", -1); + try { + GraphModel gm = currentGraphModel(); + if (gm == null) return o; + org.gephi.graph.api.GraphLock lock = gm.getGraph().getLock(); + if (lock == null) return o; + java.lang.reflect.Field f = lock.getClass().getDeclaredField("readWriteLock"); + f.setAccessible(true); + Object v = f.get(lock); + if (v instanceof java.util.concurrent.locks.ReentrantReadWriteLock) { + java.util.concurrent.locks.ReentrantReadWriteLock rwl = + (java.util.concurrent.locks.ReentrantReadWriteLock) v; + o.addProperty("readers", rwl.getReadLockCount()); + o.addProperty("write_locked", rwl.isWriteLocked()); + o.addProperty("queued", rwl.getQueueLength()); + } + } catch (Throwable t) { + // leave the -1 defaults + } + return o; + } + + // ─── View / camera control (teaching mode) ────────────────────────── + + /** + * Direct the human viewer's attention in the Gephi window: center the camera on + * the graph, a node, an edge, or a region; optionally select nodes (visual + * highlight) and set zoom. No-op modes never touch the graph write lock. + */ + public JsonObject focusView(String mode, String nodeId, String source, String target, + Double x, Double y, Double w, Double h, + Double zoom, java.util.List select) { + org.gephi.visualization.api.VisualizationController vc = + Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) return error("No visualization available (headless or view not started)"); + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + Graph g = gm.getGraph(); + try { + String m = mode == null ? "graph" : mode.toLowerCase(); + switch (m) { + case "graph": + vc.centerOnGraph(); + break; + case "zero": + vc.centerOnZero(); + break; + case "node": { + if (nodeId == null) return error("Missing 'id' for mode=node"); + Node n = g.getNode(nodeId); + if (n == null) return error("Node not found: " + nodeId); + vc.centerOnNode(n); + break; + } + case "edge": { + if (source == null || target == null) return error("Missing 'source'/'target' for mode=edge"); + Node ns = g.getNode(source), nt = g.getNode(target); + if (ns == null || nt == null) return error("Edge endpoints not found"); + Edge e = g.getEdge(ns, nt, 1); // directed + if (e == null) e = g.getEdge(ns, nt, 0); // undirected + if (e == null) e = g.getEdge(ns, nt); // default + if (e == null) e = g.getEdge(nt, ns, 1); + if (e == null) e = g.getEdge(nt, ns, 0); + if (e == null) e = g.getEdge(nt, ns); + if (e == null) return error("Edge not found: " + source + " -> " + target); + vc.centerOnEdge(e); + break; + } + case "region": { + if (x == null || y == null || w == null || h == null) + return error("Missing x/y/w/h for mode=region"); + vc.centerOn(x.floatValue(), y.floatValue(), w.floatValue(), h.floatValue()); + break; + } + default: + return error("Unknown mode: " + mode + " (use graph|zero|node|edge|region)"); + } + if (select != null) { + if (select.isEmpty()) { + vc.resetSelection(); + } else { + java.util.List nodes = new java.util.ArrayList<>(); + for (String id : select) { + Node n = g.getNode(id); + if (n != null) nodes.add(n); + } + vc.selectNodes(nodes.toArray(new Node[0])); + } + } + if (zoom != null) vc.setZoom(zoom.floatValue()); + JsonObject r = success("View focused (" + m + ")"); + r.addProperty("mode", m); + if (select != null) r.addProperty("selected", select.size()); + return r; + } catch (Exception e) { + return error("Focus failed: " + e.getMessage()); + } + } + } diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java new file mode 100644 index 000000000..b2e1d6a4f --- /dev/null +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java @@ -0,0 +1,83 @@ +package org.gephi.plugins.mcp.service; + +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.gephi.visualization.api.VisualizationController; +import org.openide.util.Lookup; + +/** + * Suspends Gephi's viz-engine world updater around external write sections. + * + * The macOS wedge happens because the renderer's world updater re-acquires the + * graph read lock near-continuously; pausing it while we hold the write lock + * removes that pressure entirely (VizEngine exposes public pauseUpdating() / + * resumeUpdating() for exactly this). Access goes through reflection on the + * concrete VizController's getEngine() so this class compiles against + * visualization-api only and degrades to a no-op wherever there is no engine + * (Gephi Toolkit, headless, or older Gephi versions). + * + * Pause/resume is reference-counted: NanoHTTPD serves requests on multiple + * threads, so concurrent write sections must not resume the renderer while a + * sibling section still holds it paused. + */ +final class RenderPause { + + private static final Logger LOGGER = Logger.getLogger(RenderPause.class.getName()); + private static final Object GATE = new Object(); + private static int depth = 0; + private static Object pausedEngine = null; + + private RenderPause() { + } + + static void pause() { + synchronized (GATE) { + depth++; + if (depth > 1) return; // already paused by a sibling section + Object engine = engine(); + if (engine == null) return; // headless / toolkit / no view: no-op + try { + engine.getClass().getMethod("pauseUpdating").invoke(engine); + pausedEngine = engine; + } catch (Throwable t) { + LOGGER.log(Level.FINE, "Renderer pause unavailable", t); + pausedEngine = null; + } + } + } + + static void resume() { + synchronized (GATE) { + if (depth == 0) return; // defensive: unmatched resume + depth--; + if (depth > 0 || pausedEngine == null) return; + try { + pausedEngine.getClass().getMethod("resumeUpdating").invoke(pausedEngine); + } catch (Throwable t) { + LOGGER.log(Level.FINE, "Renderer resume failed", t); + } finally { + pausedEngine = null; + } + } + } + + /** The live VizEngine instance, or null when no visualization is available. */ + private static Object engine() { + try { + VisualizationController controller = + Lookup.getDefault().lookup(VisualizationController.class); + if (controller == null) return null; + Method getEngine = controller.getClass().getMethod("getEngine"); + Object result = getEngine.invoke(controller); + if (result instanceof Optional) { + return ((Optional) result).orElse(null); + } + return result; + } catch (Throwable t) { + LOGGER.log(Level.FINE, "Viz engine not reachable", t); + return null; + } + } +} diff --git a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java index 52f455601..dcf2b5623 100644 --- a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java +++ b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java @@ -175,6 +175,46 @@ void lockWriteAcquiresAndReleasesViaWriteUnlock() { assertEquals(0, g.getLock().getWriteHoldCount(), "writeUnlock must release what lockWrite took"); } + /** + * Regression guard for the wedge-by-leak bug: breaking out of a live + * auto-locked NodeIterable/EdgeIterable before exhaustion leaks a read hold + * that is never released (and, on a dying request thread, never releasable), + * after which no writer can ever acquire the lock. Query endpoints must + * iterate a toArray() snapshot instead. This encodes the graphstore contract + * both patterns rely on. + */ + @Test + void earlyBreakOverToArraySnapshotLeavesNoReadHold() throws Exception { + GraphModel gm = newModel(); + for (int i = 0; i < 10; i++) { + gm.getGraph().addNode(gm.factory().newNode("n" + i)); + } + Graph g = gm.getGraph(); + + // the fixed pattern: snapshot, then break early + int count = 0; + for (org.gephi.graph.api.Node n : g.getNodes().toArray()) { + if (count >= 3) break; + count++; + } + + java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock wl = + GephiControlService.writeLockHandle(g); + assertNotNull(wl, "write lock must be reachable via reflection"); + assertTrue(wl.tryLock(200, java.util.concurrent.TimeUnit.MILLISECONDS), + "write lock must be immediately acquirable after an early-broken toArray loop"); + wl.unlock(); + + // and the trap itself, for documentation: a live-iterable early break leaks + java.util.Iterator it = g.getNodes().iterator(); + it.next(); // iterator constructor auto-acquired the read lock + assertFalse(wl.tryLock(50, java.util.concurrent.TimeUnit.MILLISECONDS), + "an unexhausted live iterator holds the read lock (the leak this guards against)"); + while (it.hasNext()) it.next(); // exhaustion releases it + assertTrue(wl.tryLock(200, java.util.concurrent.TimeUnit.MILLISECONDS)); + wl.unlock(); + } + @Test void buildCsvQuotesFieldsContainingSeparator() { GraphModel gm = newModel(); From 74a89f2bc54d0a3c49d3989852f347c151fbd778 Mon Sep 17 00:00:00 2001 From: Matt Artz Date: Sat, 4 Jul 2026 08:48:54 -0400 Subject: [PATCH 4/6] gephi-ai updates --- modules/GephiMcp/pom.xml | 6 +- .../gephi/plugins/mcp/api/GephiAPIServer.java | 15 +++- .../mcp/service/GephiControlService.java | 77 ++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml index 9eba00a9a..a20a45305 100644 --- a/modules/GephiMcp/pom.xml +++ b/modules/GephiMcp/pom.xml @@ -12,7 +12,7 @@ org.gephi.plugins gephi-mcp - 1.2.1 + 1.2.2 nbm Gephi AI (MCP) @@ -60,6 +60,10 @@ org.gephi statistics-api + + org.gephi + utils-longtask + org.gephi appearance-api diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java index 1a4eb2473..a2ab7b736 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -104,7 +104,7 @@ private JsonObject routeRequest(String uri, Method method, Map p JsonObject result = new JsonObject(); result.addProperty("success", true); result.addProperty("service", "Gephi MCP API"); - result.addProperty("version", "1.2.1"); + result.addProperty("version", "1.2.2"); result.addProperty("status", "running"); // "busy" here (persistently) means Gephi is wedged and needs a restart. result.addProperty("graph_lock", service.graphLockProbe()); @@ -468,6 +468,19 @@ private JsonObject routeRequest(String uri, Method method, Map p return service.computeModularity(res); } + if ("/statistics/available".equals(uri) && Method.GET.equals(method)) { + return service.listStatistics(); + } + + if ("/statistics/run".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("name")) return errorResult("Missing 'name'"); + Map statParams = null; + if (body.has("params") && body.get("params").isJsonObject()) { + statParams = GSON.fromJson(body.get("params"), Map.class); + } + return service.runStatisticByName(body.get("name").getAsString(), statParams); + } + if ("/statistics/degree".equals(uri) && Method.POST.equals(method)) { return service.computeDegree(); } diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java index 34e92e1d0..27c71b8b7 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -1569,6 +1569,48 @@ static Object convertLayoutProperty(Object val, Class type) { // ─── Statistics ────────────────────────────────────────────────── + /** + * Every statistic available in this Gephi instance — built-ins plus any + * installed plugin that registers a StatisticsBuilder (verified with the + * CWTS Leiden plugin). Names here are what /statistics/run accepts. + */ + public JsonObject listStatistics() { + JsonArray arr = new JsonArray(); + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + JsonObject o = new JsonObject(); + o.addProperty("name", sb.getName()); + try { + o.addProperty("id", sb.getStatistics().getClass().getSimpleName()); + } catch (Throwable t) { /* name alone is enough */ } + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("statistics", arr); + return r; + } + + /** Run any available statistic by name — the plugin-ecosystem passthrough. */ + public JsonObject runStatisticByName(String name, Map params) { + return runStatistic(name, params); + } + + private static final org.gephi.utils.progress.ProgressTicket NOOP_TICKET = + new org.gephi.utils.progress.ProgressTicket() { + public void finish() {} + public void finish(String s) {} + public void progress() {} + public void progress(int i) {} + public void progress(String s) {} + public void progress(String s, int i) {} + public String getDisplayName() { return "MCP statistic"; } + public void setDisplayName(String s) {} + public void start() {} + public void start(int i) {} + public void switchToDeterminate(int i) {} + public void switchToIndeterminate() {} + }; + private JsonObject runStatistic(String builderName, Map params) { try { Workspace ws = currentWorkspace(); @@ -1608,6 +1650,13 @@ private JsonObject runStatistic(String builderName, Map params) } } + // Plugin statistics are often LongTasks that assume the UI gave them a + // progress ticket and call it without null checks (e.g. CWTS Leiden). + // Provide a no-op ticket so they run outside the statistics dialog. + if (stat instanceof org.gephi.utils.longtask.spi.LongTask) { + ((org.gephi.utils.longtask.spi.LongTask) stat).setProgressTicket(NOOP_TICKET); + } + // Execute stat.execute(gm); @@ -1647,16 +1696,42 @@ private void setViaReflection(Object obj, String setter, Object value) { for (java.lang.reflect.Method m : obj.getClass().getMethods()) { if (m.getName().equals(methodName) && m.getParameterCount() == 1) { Class paramType = m.getParameterTypes()[0]; - Object converted = convertLayoutProperty(value, paramType); + Object converted = convertStatValue(value, paramType); if (converted != null) m.invoke(obj, converted); return; } } + // No setter: plugin statistics (e.g. the CWTS Leiden plugin) often use + // bare fields configured by their UI panel — set the field directly. + for (Class c = obj.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (java.lang.reflect.Field f : c.getDeclaredFields()) { + if (f.getName().equalsIgnoreCase(setter)) { + Object converted = convertStatValue(value, f.getType()); + if (converted != null) { + f.setAccessible(true); + f.set(obj, converted); + } + return; + } + } + } } catch (Exception e) { LOGGER.fine("Could not set " + methodName + ": " + e.getMessage()); } } + /** Value conversion for statistic parameters: layout-style primitives plus enums by name. */ + static Object convertStatValue(Object val, Class type) { + if (val != null && type.isEnum()) { + String want = val.toString(); + for (Object ec : type.getEnumConstants()) { + if (ec.toString().equalsIgnoreCase(want)) return ec; + } + return null; + } + return convertLayoutProperty(val, type); + } + private void tryAddResult(JsonObject r, Object obj, String getter, String jsonKey) { try { java.lang.reflect.Method m = obj.getClass().getMethod(getter); From 48f51109473559662f0b1df21a338026bcff8df2 Mon Sep 17 00:00:00 2001 From: Matt Artz Date: Sat, 4 Jul 2026 14:29:45 -0400 Subject: [PATCH 5/6] gephi-ai updates --- modules/GephiMcp/pom.xml | 2 +- .../gephi/plugins/mcp/api/GephiAPIServer.java | 2 +- .../mcp/service/GephiControlService.java | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml index a20a45305..ff09cccbd 100644 --- a/modules/GephiMcp/pom.xml +++ b/modules/GephiMcp/pom.xml @@ -12,7 +12,7 @@ org.gephi.plugins gephi-mcp - 1.2.2 + 1.2.3 nbm Gephi AI (MCP) diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java index a2ab7b736..22cbabdaf 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -104,7 +104,7 @@ private JsonObject routeRequest(String uri, Method method, Map p JsonObject result = new JsonObject(); result.addProperty("success", true); result.addProperty("service", "Gephi MCP API"); - result.addProperty("version", "1.2.2"); + result.addProperty("version", "1.2.3"); result.addProperty("status", "running"); // "busy" here (persistently) means Gephi is wedged and needs a restart. result.addProperty("graph_lock", service.graphLockProbe()); diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java index 27c71b8b7..f3053e845 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -1967,6 +1967,26 @@ public JsonObject setPreviewSettings(Map settings) { } PreviewProperty prop = pm.getProperties().getProperty(key); + if (prop == null) { + // Property registry may not be initialized in this workspace + // (e.g. Preview never opened). putValue works regardless and + // renderers read it at export time. + Object coerced = val; + if (val instanceof String) { + String sv = ((String) val).trim(); + if (sv.equalsIgnoreCase("true") || sv.equalsIgnoreCase("false")) coerced = Boolean.parseBoolean(sv); + else { + try { coerced = Float.parseFloat(sv); } catch (NumberFormatException ignore) { } + } + } else if (val instanceof Number) { + coerced = ((Number) val).floatValue(); + } else if (val instanceof Boolean) { + coerced = val; + } + pm.getProperties().putValue(key, coerced); + set++; + continue; + } if (prop != null) { // Convert value based on property type Class type = prop.getType(); From fba6249c5074699c58b18d01864e59cf016e4903 Mon Sep 17 00:00:00 2001 From: Matt Artz Date: Tue, 7 Jul 2026 20:23:19 -0400 Subject: [PATCH 6/6] gephi-ai updates --- modules/GephiMcp/pom.xml | 14 +- .../gephi/plugins/mcp/api/GephiAPIServer.java | 113 ++- .../mcp/service/GephiControlService.java | 716 +++++++++++++++++- .../plugins/mcp/service/GraphOpsTest.java | 101 +++ 4 files changed, 933 insertions(+), 11 deletions(-) diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml index ff09cccbd..fd9a8c549 100644 --- a/modules/GephiMcp/pom.xml +++ b/modules/GephiMcp/pom.xml @@ -12,7 +12,7 @@ org.gephi.plugins gephi-mcp - 1.2.3 + 1.2.12 nbm Gephi AI (MCP) @@ -80,6 +80,18 @@ org.gephi visualization-api + + org.gephi + perspective-api + + + org.gephi + datalab-api + + + org.gephi + timeline-api + diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java index 22cbabdaf..b8399f414 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -104,14 +104,24 @@ private JsonObject routeRequest(String uri, Method method, Map p JsonObject result = new JsonObject(); result.addProperty("success", true); result.addProperty("service", "Gephi MCP API"); - result.addProperty("version", "1.2.3"); + result.addProperty("version", "1.2.12"); result.addProperty("status", "running"); // "busy" here (persistently) means Gephi is wedged and needs a restart. result.addProperty("graph_lock", service.graphLockProbe()); result.add("graph_lock_stats", service.graphLockStats()); + // Start capturing the human's node clicks from the session's first call. + service.ensureClickListener(); return result; } + // ─── Human selection journal ───────────────────────────────── + + if ("/selection".equals(uri) && Method.GET.equals(method)) { + // Default false, matching the Python tool: peeking must not consume. + boolean clear = "true".equalsIgnoreCase(params.get("clear")); + return service.getSelection(clear); + } + // ─── View / camera (teaching mode) ─────────────────────────── if ("/view/focus".equals(uri) && Method.POST.equals(method)) { @@ -134,6 +144,20 @@ private JsonObject routeRequest(String uri, Method method, Map p return service.focusView(mode, id, source, target, x, y, w, h, zoom, select); } + if ("/view/selection".equals(uri) && Method.POST.equals(method)) { + String mode = body != null && body.has("mode") ? body.get("mode").getAsString() : "rectangle"; + return service.setSelectionMode(mode); + } + + if ("/perspective".equals(uri) && Method.GET.equals(method)) { + return service.getPerspective(); + } + + if ("/perspective/switch".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("name")) return errorResult("Missing 'name'"); + return service.switchPerspective(body.get("name").getAsString()); + } + // ─── Project ───────────────────────────────────────────────── if ("/project/new".equals(uri) && Method.POST.equals(method)) { @@ -257,7 +281,8 @@ private JsonObject routeRequest(String uri, Method method, Map p String target = body.get("target").getAsString(); Double weight = body.has("weight") ? body.get("weight").getAsDouble() : 1.0; boolean directed = !body.has("directed") || body.get("directed").getAsBoolean(); - return service.addEdge(source, target, weight, directed); + String edgeType = body.has("edge_type") ? body.get("edge_type").getAsString() : null; + return service.addEdge(source, target, weight, directed, edgeType); } if ("/graph/edges/add".equals(uri) && Method.POST.equals(method)) { @@ -401,6 +426,21 @@ private JsonObject routeRequest(String uri, Method method, Map p return service.colorByPartition(column, colorMap); } + if ("/appearance/edge/partition-color".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column")) return errorResult("Missing 'column'"); + String column = body.get("column").getAsString(); + Map colorMap = null; + if (body.has("colors") && body.get("colors").isJsonObject()) { + colorMap = new HashMap<>(); + JsonObject colors = body.getAsJsonObject("colors"); + for (String key : colors.keySet()) { + List rgb = GSON.fromJson(colors.get(key), List.class); + colorMap.put(key, new int[]{rgb.get(0).intValue(), rgb.get(1).intValue(), rgb.get(2).intValue()}); + } + } + return service.colorEdgesByPartition(column, colorMap); + } + if ("/appearance/ranking/color".equals(uri) && Method.POST.equals(method)) { if (body == null || !body.has("column")) return errorResult("Missing 'column'"); String column = body.get("column").getAsString(); @@ -554,6 +594,60 @@ private JsonObject routeRequest(String uri, Method method, Map p return service.resetFilters(); } + if ("/filter/list".equals(uri) && Method.GET.equals(method)) { + return service.listFilters(); + } + + if ("/filter/apply".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("name")) return errorResult("Missing 'name'"); + String fname = body.get("name").getAsString(); + Map filterParams = body.has("params") ? GSON.fromJson(body.get("params"), Map.class) : null; + String action = body.has("action") ? body.get("action").getAsString() : "select"; + String column = body.has("column") ? body.get("column").getAsString() : null; + return service.applyFilter(fname, filterParams, action, column); + } + + // ─── Data Laboratory ───────────────────────────────────────── + + if ("/datalab/frequencies".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column")) return errorResult("Missing 'column'"); + String target = body.has("target") ? body.get("target").getAsString() : "node"; + return service.columnValueFrequencies(target, body.get("column").getAsString()); + } + + if ("/datalab/duplicates".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column")) return errorResult("Missing 'column'"); + String target = body.has("target") ? body.get("target").getAsString() : "node"; + boolean cs = body.has("case_sensitive") && body.get("case_sensitive").getAsBoolean(); + return service.detectDuplicates(target, body.get("column").getAsString(), cs); + } + + if ("/datalab/merge-nodes".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("ids")) return errorResult("Missing 'ids'"); + java.util.List ids = GSON.fromJson(body.get("ids"), java.util.List.class); + String into = body.has("into") ? body.get("into").getAsString() : null; + return service.mergeNodes(ids, into); + } + + if ("/datalab/regex-column".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("column") || !body.has("new_column") || !body.has("regex")) + return errorResult("Missing 'column', 'new_column', or 'regex'"); + String target = body.has("target") ? body.get("target").getAsString() : "node"; + return service.createRegexColumn(target, body.get("column").getAsString(), + body.get("new_column").getAsString(), body.get("regex").getAsString()); + } + + // ─── Timeline (read-only) ──────────────────────────────────── + // NOTE: there is deliberately NO write endpoint here. Driving Gephi's + // timeline from outside (setInterval/setEnabled, or a time-derived + // setVisibleView) wedges the EDT, and Gephi's own shutdown runs on the + // EDT — so a wedged timeline op makes the app impossible to quit + // normally (Force Quit only). getTimeline is a pure read and is safe. + + if ("/timeline".equals(uri) && Method.GET.equals(method)) { + return service.getTimeline(); + } + // ─── Edge Appearance ──────────────────────────────────────── if ("/appearance/edge/thickness-by-weight".equals(uri) && Method.POST.equals(method)) { @@ -570,7 +664,14 @@ private JsonObject routeRequest(String uri, Method method, Map p if ("/preview/settings".equals(uri) && Method.POST.equals(method)) { if (body == null) return errorResult("Missing request body"); - Map settings = GSON.fromJson(body, Map.class); + // Body shape is flat {property: value}; unwrap the common client + // mistake of nesting everything under a "settings" key so it does + // not get stored as a junk preview property named "settings". + JsonObject effective = body; + if (body.size() == 1 && body.has("settings") && body.get("settings").isJsonObject()) { + effective = body.getAsJsonObject("settings"); + } + Map settings = GSON.fromJson(effective, Map.class); return service.setPreviewSettings(settings); } @@ -585,6 +686,12 @@ private JsonObject routeRequest(String uri, Method method, Map p return service.exportGexf(body.get("file").getAsString()); } + if ("/export/format".equals(uri) && Method.POST.equals(method)) { + if (body == null || !body.has("file") || !body.has("format")) + return errorResult("Missing 'file' or 'format'"); + return service.exportByFormat(body.get("file").getAsString(), body.get("format").getAsString()); + } + if ("/export/png".equals(uri) && Method.POST.equals(method)) { if (body == null || !body.has("file")) return errorResult("Missing 'file'"); String file = body.get("file").getAsString(); diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java index f3053e845..989a1a08d 100644 --- a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -34,6 +34,12 @@ import org.gephi.io.exporter.spi.CharacterExporter; import org.gephi.io.exporter.spi.Exporter; import org.gephi.io.exporter.spi.GraphExporter; +import org.gephi.filters.api.FilterController; +import org.gephi.filters.api.Query; +import org.gephi.filters.spi.CategoryBuilder; +import org.gephi.filters.spi.Filter; +import org.gephi.filters.spi.FilterBuilder; +import org.gephi.filters.spi.FilterProperty; import org.gephi.io.importer.api.Container; import org.gephi.io.importer.api.ImportController; import org.gephi.io.processor.spi.Processor; @@ -64,6 +70,14 @@ public class GephiControlService { // Stored when setPreviewSettings receives background.color — used by exportPng to composite background private volatile Color exportBackgroundColor = null; + // Human click journal: the person's node clicks in the Gephi window, + // recorded by a passive viz-event listener so the model can resolve + // "this one" / "these" to actual nodes. Bounded; strings only (never + // hold Node references — they outlive workspaces). + private static final int CLICK_JOURNAL_MAX = 50; + private final java.util.ArrayDeque clickJournal = new java.util.ArrayDeque<>(); + private volatile boolean clickListenerInstalled = false; + private GephiControlService() {} public static synchronized GephiControlService getInstance() { @@ -736,13 +750,30 @@ public JsonObject batchSetPositions(List> positions) { // ─── Edge Operations ───────────────────────────────────────────── public JsonObject addEdge(String src, String tgt, Double weight, boolean directed) { + return addEdge(src, tgt, weight, directed, null); + } + + public JsonObject addEdge(String src, String tgt, Double weight, boolean directed, String edgeType) { Workspace ws = currentWorkspace(); if (ws == null) return error("No project open"); - return addEdgeToModel(getGraphController().getGraphModel(ws), src, tgt, weight, directed); + return addEdgeToModel(getGraphController().getGraphModel(ws), src, tgt, weight, directed, edgeType); } /** Core edge-add against an explicit model. Type and directedness are kept consistent. */ static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight, boolean directed) { + return addEdgeToModel(gm, src, tgt, weight, directed, null); + } + + /** + * Core edge-add, with an optional relationship type. When edgeType is null + * or blank the behavior is exactly as before: one edge per (source, target), + * type 0/1 by directedness. When edgeType is given, the edge is created under + * that named type (GraphStore's native typed parallel edges) and the + * duplicate check is scoped to that type — so A→B can carry a "cites" edge + * AND a "coauthor" edge at once, while a second "cites" A→B is still blocked. + */ + static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight, + boolean directed, String edgeType) { try { Graph g = gm.getGraph(); lockWrite(g); @@ -750,9 +781,15 @@ static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double w Node s = g.getNode(src), t = g.getNode(tgt); if (s == null) return error("Source not found: " + src); if (t == null) return error("Target not found: " + tgt); - if (findEdge(g, s, t) != null) return error("Edge exists"); - Edge e = gm.factory().newEdge(s, t, directed ? 1 : 0, weight != null ? weight : 1.0, directed); - g.addEdge(e); + double w = weight != null ? weight : 1.0; + if (edgeType != null && !edgeType.isEmpty()) { + int typeId = gm.addEdgeType(edgeType); + if (g.getEdge(s, t, typeId) != null) return error("Edge of type '" + edgeType + "' exists"); + g.addEdge(gm.factory().newEdge(s, t, typeId, w, directed)); + } else { + if (findEdge(g, s, t) != null) return error("Edge exists"); + g.addEdge(gm.factory().newEdge(s, t, directed ? 1 : 0, w, directed)); + } return success("Edge added"); } finally { unlockWrite(g); } } catch (Exception e) { return error("Failed: " + e.getMessage()); } @@ -776,10 +813,20 @@ static JsonObject addEdgesToModel(GraphModel gm, List> edges String tgt = (String) ed.get("target"); if (src == null || tgt == null) { skipped++; continue; } Node s = g.getNode(src), t = g.getNode(tgt); - if (s == null || t == null || findEdge(g, s, t) != null) { skipped++; continue; } + if (s == null || t == null) { skipped++; continue; } Double w = ed.containsKey("weight") ? ((Number) ed.get("weight")).doubleValue() : 1.0; boolean directed = !ed.containsKey("directed") || Boolean.TRUE.equals(ed.get("directed")); - Edge e = gm.factory().newEdge(s, t, directed ? 1 : 0, w, directed); + Object edgeTypeObj = ed.get("edge_type"); + String edgeType = edgeTypeObj != null ? edgeTypeObj.toString() : null; + int type; + if (edgeType != null && !edgeType.isEmpty()) { + type = gm.addEdgeType(edgeType); + if (g.getEdge(s, t, type) != null) { skipped++; continue; } + } else { + if (findEdge(g, s, t) != null) { skipped++; continue; } + type = directed ? 1 : 0; + } + Edge e = gm.factory().newEdge(s, t, type, w, directed); Object label = ed.get("label"); if (label != null) e.setLabel(label.toString()); g.addEdge(e); @@ -1970,7 +2017,13 @@ public JsonObject setPreviewSettings(Map settings) { if (prop == null) { // Property registry may not be initialized in this workspace // (e.g. Preview never opened). putValue works regardless and - // renderers read it at export time. + // renderers read it at export time. Non-scalar values are + // never valid preview properties — storing one corrupts the + // model, so skip them. + if (val instanceof Map || val instanceof List) { + LOGGER.warning("MCP: Skipping non-scalar preview value for " + key); + continue; + } Object coerced = val; if (val instanceof String) { String sv = ((String) val).trim(); @@ -2673,6 +2726,131 @@ public JsonObject graphLockStats() { return o; } + // ─── Human selection journal ───────────────────────────────────────── + + /** + * Install the passive NODE_LEFT_CLICK listener once. Safe to call often; + * no-ops until the visualization is available. The listener returns false + * (observe, never consume) so Gephi's own tools keep working. + */ + public synchronized void ensureClickListener() { + if (clickListenerInstalled) return; + org.gephi.visualization.api.VisualizationController vc = + Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) return; + vc.addListener(new org.gephi.visualization.api.VisualizationEventListener() { + @Override + public boolean handleEvent(org.gephi.visualization.api.VisualizationEvent event) { + try { + Object data = event.getData(); + if (data instanceof Node[]) { + Node[] nodes = (Node[]) data; + if (nodes.length > 0) recordClick(nodes); + } + } catch (Throwable t) { + // Never disturb the viz event thread. + } + return false; + } + + @Override + public org.gephi.visualization.api.VisualizationEvent.Type getType() { + return org.gephi.visualization.api.VisualizationEvent.Type.NODE_LEFT_CLICK; + } + }); + clickListenerInstalled = true; + } + + private void recordClick(Node[] nodes) { + JsonObject entry = new JsonObject(); + entry.addProperty("time_ms", System.currentTimeMillis()); + JsonArray arr = new JsonArray(); + for (Node n : nodes) { + JsonObject jn = new JsonObject(); + jn.addProperty("id", String.valueOf(n.getId())); + String label = n.getLabel(); + if (label != null && !label.isEmpty() && !label.equals(String.valueOf(n.getId()))) { + jn.addProperty("label", label); + } + arr.add(jn); + } + entry.add("nodes", arr); + synchronized (clickJournal) { + clickJournal.addLast(entry); + while (clickJournal.size() > CLICK_JOURNAL_MAX) clickJournal.removeFirst(); + } + } + + private static final int SELECTION_MAX_NODES = 200; + + private JsonObject nodeRef(Node n) { + JsonObject jn = new JsonObject(); + jn.addProperty("id", String.valueOf(n.getId())); + String label = n.getLabel(); + if (label != null && !label.isEmpty() && !label.equals(String.valueOf(n.getId()))) { + jn.addProperty("label", label); + } + return jn; + } + + /** + * What the human has selected in the Gephi window. Two sources: + * selected_now — the engine's persistent selection (rectangle selection + * keeps it after the mouse moves away; the primary channel), read via + * reflection (VizController.getEngine() -> VizEngine.getGraphSelection() + * -> getSelectedNodes(), all public, reflection only to avoid a + * compile-time dependency on the engine module); clicks — the + * NODE_LEFT_CLICK journal (fires only in modes that populate the engine + * selection at click time). clear=true consumes the journal only; the + * live selection always reflects the canvas. + */ + public JsonObject getSelection(boolean clear) { + ensureClickListener(); + JsonObject r = success("Human selection"); + JsonArray selected = new JsonArray(); + int totalSelected = 0; + try { + Object vc = Lookup.getDefault().lookup( + org.gephi.visualization.api.VisualizationController.class); + if (vc != null) { + Object opt = vc.getClass().getMethod("getEngine").invoke(vc); + if (opt instanceof java.util.Optional && ((java.util.Optional) opt).isPresent()) { + Object engine = ((java.util.Optional) opt).get(); + Object gsel = engine.getClass().getMethod("getGraphSelection").invoke(engine); + if (gsel != null) { + java.lang.reflect.Method m = gsel.getClass().getMethod("getSelectedNodes"); + m.setAccessible(true); + Object coll = m.invoke(gsel); + if (coll instanceof java.util.Collection) { + for (Object o : (java.util.Collection) coll) { + totalSelected++; + if (o instanceof Node && selected.size() < SELECTION_MAX_NODES) { + selected.add(nodeRef((Node) o)); + } + } + } + } + } + } + } catch (Throwable t) { + r.addProperty("selection_error", t.getClass().getSimpleName() + ": " + t.getMessage()); + } + r.add("selected_now", selected); + r.addProperty("selected_count", totalSelected); + if (totalSelected > SELECTION_MAX_NODES) { + r.addProperty("selected_truncated", true); + } + JsonArray clicks = new JsonArray(); + synchronized (clickJournal) { + for (JsonObject e : clickJournal) clicks.add(e.deepCopy()); + if (clear) clickJournal.clear(); + } + r.add("clicks", clicks); + r.addProperty("click_count", clicks.size()); + r.addProperty("listener_active", clickListenerInstalled); + return r; + } + // ─── View / camera control (teaching mode) ────────────────────────── /** @@ -2750,4 +2928,528 @@ public JsonObject focusView(String mode, String nodeId, String source, String ta } } + /** + * Set the mouse selection mode on the graph canvas. "rectangle" enables the + * box-drag selection the pointing feature (readSelection) reads, so a + * teaching session can turn it on up front instead of asking the human to + * click the toolbar icon. Uses the same VisualizationController focusView + * already drives. + */ + public JsonObject setSelectionMode(String mode) { + org.gephi.visualization.api.VisualizationController vc = + Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) return error("No visualization available (headless or view not started)"); + String m = mode == null ? "rectangle" : mode.toLowerCase(); + try { + switch (m) { + case "rectangle": + vc.setRectangleSelection(); + break; + case "direct": + vc.setDirectMouseSelection(); + break; + case "disable": + case "off": + vc.disableSelection(); + break; + default: + return error("Unknown selection mode: " + mode + " (use rectangle|direct|disable)"); + } + JsonObject r = success("Selection mode set to " + m); + r.addProperty("mode", m); + return r; + } catch (Exception e) { + return error("Set selection mode failed: " + e.getMessage()); + } + } + + /** List the perspectives (Overview / Data Laboratory / Preview) and the active one. */ + public JsonObject getPerspective() { + org.gephi.perspective.api.PerspectiveController pc = + Lookup.getDefault().lookup(org.gephi.perspective.api.PerspectiveController.class); + if (pc == null) return error("No perspective controller (headless?)"); + try { + org.gephi.perspective.spi.Perspective selected = pc.getSelectedPerspective(); + JsonObject r = success("Perspectives listed"); + r.addProperty("selected", selected == null ? null : selected.getName()); + com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); + for (org.gephi.perspective.spi.Perspective p : pc.getPerspectives()) { + JsonObject o = new JsonObject(); + o.addProperty("name", p.getName()); + o.addProperty("display_name", p.getDisplayName()); + o.addProperty("selected", p == selected); + arr.add(o); + } + r.add("perspectives", arr); + return r; + } catch (Exception e) { + return error("List perspectives failed: " + e.getMessage()); + } + } + + /** Switch the active perspective (tab) by name or display name (case-insensitive). */ + public JsonObject switchPerspective(String name) { + org.gephi.perspective.api.PerspectiveController pc = + Lookup.getDefault().lookup(org.gephi.perspective.api.PerspectiveController.class); + if (pc == null) return error("No perspective controller (headless?)"); + if (name == null) return error("Missing 'name'"); + org.gephi.perspective.spi.Perspective match = null; + for (org.gephi.perspective.spi.Perspective p : pc.getPerspectives()) { + if (name.equalsIgnoreCase(p.getName()) || name.equalsIgnoreCase(p.getDisplayName())) { + match = p; + break; + } + } + if (match == null) return error("Perspective not found: " + name); + final org.gephi.perspective.spi.Perspective target = match; + // Switching the perspective mutates the NetBeans window system — do it on the EDT. + return runOnEDT(() -> { + pc.selectPerspective(target); + JsonObject r = success("Switched to perspective: " + target.getDisplayName()); + r.addProperty("selected", target.getName()); + return r; + }); + } + + // ─── Filters (Group C) ─────────────────────────────────────────── + + /** + * Every filter builder available, static and dynamic. Static builders + * (DegreeRange, KCore, GiantComponent, Ego, …) come straight from Lookup; + * per-column attribute builders (AttributeEqual/Range/NonNull on each + * column) come from CategoryBuilder.getBuilders(workspace) and only exist + * once a graph with columns is loaded. + */ + private java.util.List allFilterBuilders(Workspace ws) { + java.util.List out = new java.util.ArrayList<>(); + for (FilterBuilder b : Lookup.getDefault().lookupAll(FilterBuilder.class)) { + out.add(b); + } + for (CategoryBuilder cb : Lookup.getDefault().lookupAll(CategoryBuilder.class)) { + try { + FilterBuilder[] bs = cb.getBuilders(ws); + if (bs != null) java.util.Collections.addAll(out, bs); + } catch (Exception ignore) { /* some category builders need a specific state */ } + } + return out; + } + + /** Coerce a JSON value to a filter property's type; handles Range from a [lo, hi] pair. */ + static Object convertFilterProperty(Object val, Class type) { + if (val == null) return null; + if (type == org.gephi.filters.api.Range.class) { + java.util.List pair = null; + if (val instanceof java.util.List) pair = (java.util.List) val; + else if (val instanceof com.google.gson.JsonArray) { + java.util.List l = new java.util.ArrayList<>(); + for (com.google.gson.JsonElement e : (com.google.gson.JsonArray) val) l.add(e.getAsDouble()); + pair = l; + } + if (pair == null || pair.size() != 2) return null; + double loD = pair.get(0) instanceof Number ? ((Number) pair.get(0)).doubleValue() : Double.parseDouble(pair.get(0).toString()); + double hiD = pair.get(1) instanceof Number ? ((Number) pair.get(1)).doubleValue() : Double.parseDouble(pair.get(1).toString()); + // Range requires both bounds to be the SAME Number class. Use Integer when + // both are whole (degree/count filters), Double otherwise (continuous columns). + boolean whole = loD == Math.floor(loD) && hiD == Math.floor(hiD) + && !Double.isInfinite(loD) && !Double.isInfinite(hiD); + if (whole) return new org.gephi.filters.api.Range((int) loD, (int) hiD); + return new org.gephi.filters.api.Range(loD, hiD); + } + return convertLayoutProperty(val, type); + } + + public JsonObject listFilters() { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No workspace open"); + JsonArray arr = new JsonArray(); + for (FilterBuilder b : allFilterBuilders(ws)) { + JsonObject o = new JsonObject(); + try { o.addProperty("name", b.getName()); } catch (Exception ignore) {} + try { o.addProperty("category", b.getCategory() == null ? null : b.getCategory().getName()); } catch (Exception ignore) {} + try { o.addProperty("description", b.getDescription()); } catch (Exception ignore) {} + // Introspect the filter's settable properties so callers know what params to pass. + try { + Filter f = b.getFilter(ws); + if (f != null && f.getProperties() != null) { + JsonArray props = new JsonArray(); + for (FilterProperty p : f.getProperties()) { + JsonObject po = new JsonObject(); + po.addProperty("name", p.getName()); + po.addProperty("type", p.getValueType() == null ? null : p.getValueType().getSimpleName()); + props.add(po); + } + o.add("properties", props); + } + } catch (Exception ignore) { /* introspection best-effort */ } + arr.add(o); + } + JsonObject r = success("Filters listed"); + r.add("filters", arr); + return r; + } + + public JsonObject applyFilter(String name, Map params, String action, String column) { + FilterController fc = Lookup.getDefault().lookup(FilterController.class); + if (fc == null) return error("No filter controller available"); + Workspace ws = currentWorkspace(); + if (ws == null) return error("No workspace open"); + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + if (name == null) return error("Missing 'name'"); + + FilterBuilder builder = null; + for (FilterBuilder b : allFilterBuilders(ws)) { + try { if (name.equalsIgnoreCase(b.getName())) { builder = b; break; } } catch (Exception ignore) {} + } + if (builder == null) return error("Filter not found: " + name + " (call /filter/list to see available filters)"); + + Filter filter = builder.getFilter(ws); + if (filter == null) return error("Filter builder produced no filter: " + name); + + // Set each named property; report the valid names if a param doesn't match. + FilterProperty[] props = filter.getProperties(); + if (params != null && !params.isEmpty()) { + java.util.List propNames = new java.util.ArrayList<>(); + if (props != null) for (FilterProperty p : props) propNames.add(p.getName()); + for (Map.Entry e : params.entrySet()) { + FilterProperty match = null; + if (props != null) { + for (FilterProperty p : props) { + if (e.getKey().equalsIgnoreCase(p.getName())) { match = p; break; } + } + } + if (match == null) { + return error("Unknown filter property '" + e.getKey() + "' for " + name + + " — valid properties: " + propNames); + } + Object converted = convertFilterProperty(e.getValue(), match.getValueType()); + if (converted == null) { + return error("Could not coerce '" + e.getKey() + "' to " + match.getValueType().getSimpleName() + + " (Range wants a [lo, hi] pair)"); + } + try { match.setValue(converted); } + catch (Exception ex) { return error("Failed to set '" + e.getKey() + "': " + ex.getMessage()); } + } + } + + int nodesBefore = gm.getGraphVisible().getNodeCount(); + int edgesBefore = gm.getGraphVisible().getEdgeCount(); + + Query query = fc.createQuery(filter); + fc.add(query); + + String act = action == null ? "select" : action.toLowerCase(); + JsonObject r; + switch (act) { + case "select": + case "visible": + fc.filterVisible(query); + r = success("Filter applied to the visible graph"); + r.addProperty("nodes_before", nodesBefore); + r.addProperty("edges_before", edgesBefore); + r.addProperty("nodes_after", gm.getGraphVisible().getNodeCount()); + r.addProperty("edges_after", gm.getGraphVisible().getEdgeCount()); + break; + case "new_workspace": + // Materializes the filtered subgraph into a fresh workspace — the + // memory-safe way to filter repeatedly (hidden GraphView elements + // otherwise stay resident). + fc.exportToNewWorkspace(query); + r = success("Filtered subgraph exported to a new workspace"); + break; + case "column": + if (column == null) return error("action=column requires a 'column' name"); + fc.exportToColumn(column, query); + r = success("Filter membership written to boolean column: " + column); + r.addProperty("column", column); + break; + default: + return error("Unknown action: " + action + " (use select|new_workspace|column)"); + } + try { r.addProperty("filter", builder.getName()); } catch (Exception ignore) {} + return r; + } + + // ─── Data Laboratory (Group D) ─────────────────────────────────── + + private static Table tableFor(GraphModel gm, String target) { + return "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable(); + } + + private static org.gephi.graph.api.Element[] elementsFor(GraphModel gm, String target) { + Graph g = gm.getGraph(); + return "edge".equalsIgnoreCase(target) ? g.getEdges().toArray() : g.getNodes().toArray(); + } + + /** + * Value -> count over one column. Pure GraphModel logic (no datalab + * controller / running Gephi needed), so it is unit-testable against an + * in-memory model. + */ + static JsonObject columnValueFrequenciesCore(GraphModel gm, String target, String columnId) { + Table table = tableFor(gm, target); + Column col = table.getColumn(columnId); + if (col == null) return error("Column not found: " + columnId); + java.util.LinkedHashMap freq = new java.util.LinkedHashMap<>(); + int total = 0; + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + Object v = el.getAttribute(col); + String key = v == null ? "" : v.toString(); + freq.merge(key, 1, Integer::sum); + total++; + } + JsonObject r = success("Column value frequencies computed"); + r.addProperty("column", columnId); + r.addProperty("target", "edge".equalsIgnoreCase(target) ? "edge" : "node"); + r.addProperty("total", total); + r.addProperty("distinct_values", freq.size()); + JsonObject f = new JsonObject(); + for (Map.Entry e : freq.entrySet()) f.addProperty(e.getKey(), e.getValue()); + r.add("frequencies", f); + return r; + } + + /** + * Groups of elements that share a value in one column (size >= 2). Pure + * GraphModel logic, unit-testable. caseSensitive controls string matching. + */ + static JsonObject detectDuplicatesCore(GraphModel gm, String target, String columnId, boolean caseSensitive) { + Table table = tableFor(gm, target); + Column col = table.getColumn(columnId); + if (col == null) return error("Column not found: " + columnId); + java.util.LinkedHashMap> groups = new java.util.LinkedHashMap<>(); + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + Object v = el.getAttribute(col); + if (v == null) continue; + String key = v.toString(); + if (!caseSensitive) key = key.toLowerCase(); + groups.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(String.valueOf(el.getId())); + } + JsonArray dupes = new JsonArray(); + int groupCount = 0; + for (java.util.List ids : groups.values()) { + if (ids.size() >= 2) { + groupCount++; + JsonArray a = new JsonArray(); + for (String id : ids) a.add(id); + dupes.add(a); + } + } + JsonObject r = success("Duplicate detection complete"); + r.addProperty("column", columnId); + r.addProperty("group_count", groupCount); + r.add("duplicate_groups", dupes); + return r; + } + + public JsonObject columnValueFrequencies(String target, String columnId) { + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + if (columnId == null) return error("Missing 'column'"); + return columnValueFrequenciesCore(gm, target, columnId); + } + + public JsonObject detectDuplicates(String target, String columnId, boolean caseSensitive) { + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + if (columnId == null) return error("Missing 'column'"); + return detectDuplicatesCore(gm, target, columnId, caseSensitive); + } + + /** Merge several nodes into one, reassigning edges; deletes the merged-away nodes. */ + public JsonObject mergeNodes(java.util.List ids, String intoId) { + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + if (ids == null || ids.isEmpty()) return error("Missing 'ids'"); + org.gephi.datalab.api.GraphElementsController gec = + Lookup.getDefault().lookup(org.gephi.datalab.api.GraphElementsController.class); + if (gec == null) return error("No datalab controller available"); + Graph g = gm.getGraph(); + java.util.List nodes = new java.util.ArrayList<>(); + for (String id : ids) { + Node n = g.getNode(id); + if (n == null) return error("Node not found: " + id); + nodes.add(n); + } + Node into = intoId != null ? g.getNode(intoId) : nodes.get(0); + if (into == null) return error("Merge target node not found: " + intoId); + try { + // Empty column/strategy arrays: reassign edges and keep the `into` node's + // own attribute values (no per-column value merge). Passing null throws + // an NPE inside the controller (it reads columns.length). + Node result = gec.mergeNodes(g, nodes.toArray(new Node[0]), into, + new Column[0], new org.gephi.datalab.spi.rows.merge.AttributeRowsMergeStrategy[0], true); + JsonObject r = success("Merged " + nodes.size() + " nodes"); + r.addProperty("into", result != null ? String.valueOf(result.getId()) : String.valueOf(into.getId())); + r.addProperty("merged_count", nodes.size()); + return r; + } catch (Exception e) { + return error("Merge failed: " + e.getMessage()); + } + } + + // ─── Edge appearance + generic export (Group E) ────────────────── + + /** + * Color edges by an edge-column partition (relationship type, time period, + * weight tier, …) — the edge twin of colorByPartition. Mirrors it exactly: + * per-value palette (supplied or auto), then edge.setColor per row. + */ + public JsonObject colorEdgesByPartition(String columnName, Map colorMap) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = gm.getEdgeTable().getColumn(columnName); + if (col == null) return error("Edge column not found: " + columnName); + + java.util.Map palette = new java.util.LinkedHashMap<>(); + if (colorMap != null && !colorMap.isEmpty()) { + for (Map.Entry e : colorMap.entrySet()) { + int[] c = e.getValue(); + palette.put(e.getKey(), new Color(c[0], c[1], c[2])); + } + } else { + java.util.Set values = new java.util.LinkedHashSet<>(); + for (Edge ed : graph.getEdges().toArray()) { + Object v = ed.getAttribute(col); + if (v != null) values.add(v.toString()); + } + Color[] defaultPalette = { + new Color(31, 119, 180), new Color(255, 127, 14), new Color(44, 160, 44), + new Color(214, 39, 40), new Color(148, 103, 189), new Color(140, 86, 75), + new Color(227, 119, 194), new Color(127, 127, 127), new Color(188, 189, 34), + new Color(23, 190, 207), new Color(174, 199, 232), new Color(255, 187, 120) + }; + int idx = 0; + for (String v : values) { palette.put(v, defaultPalette[idx % defaultPalette.length]); idx++; } + } + + int colored = 0; + lockWrite(graph); + try { + for (Edge ed : graph.getEdges().toArray()) { + Object v = ed.getAttribute(col); + if (v != null) { + Color c = palette.get(v.toString()); + if (c != null) { ed.setColor(c); colored++; } + } + } + } finally { unlockWrite(graph); } + JsonObject r = success("Colored " + colored + " edges by " + columnName); + r.addProperty("partitions", palette.size()); + return r; + } catch (Exception e) { return error("Failed: " + e.getMessage()); } + }); + } + + /** + * Export the graph in any format the ExportController knows by name — vna, + * pajek, dl, spreadsheet, gdf, gml, json, gexf, graphml, csv — for + * interchange with UCINET and other SNA tools, or a spreadsheet for + * non-technical readers. The wrapped-today formats (gexf/graphml/csv) keep + * their dedicated tools; this is the passthrough for the rest. + */ + public JsonObject exportByFormat(String filePath, String format) { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + if (ws == null) return error("No project open"); + if (filePath == null || format == null) return error("Missing 'file' or 'format'"); + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter(format); + if (exporter == null) return error("No exporter for format: " + format + + " (try vna, pajek, dl, spreadsheet, gdf, gml, json, gexf, graphml, csv)"); + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(true); + ((GraphExporter) exporter).setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + JsonObject r = success("Exported to " + filePath); + r.addProperty("format", format); + return r; + } catch (Exception e) { return error("Export failed: " + e.getMessage()); } + }); + } + + // ─── Timeline / dynamic (Group G) ──────────────────────────────── + + /** + * Report the graph's dynamic/timeline state. Doubles as the spike for the + * reported "Timeline doesn't recognize dynamic attributes after a + * programmatic import" bug: if graph_is_dynamic is true but + * dynamic_columns is empty, the bug reproduces on this Gephi. + */ + public JsonObject getTimeline() { + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + JsonObject r = success("Timeline state"); + try { + r.addProperty("graph_is_dynamic", gm.isDynamic()); + org.gephi.graph.api.Interval b = gm.getTimeBounds(); + if (b != null) { + r.addProperty("time_min", b.getLow()); + r.addProperty("time_max", b.getHigh()); + } + r.addProperty("time_format", String.valueOf(gm.getTimeFormat())); + } catch (Exception e) { r.addProperty("bounds_error", e.getMessage()); } + org.gephi.timeline.api.TimelineController tc = + Lookup.getDefault().lookup(org.gephi.timeline.api.TimelineController.class); + if (tc != null) { + try { + JsonArray cols = new JsonArray(); + String[] dc = tc.getDynamicGraphColumns(); + if (dc != null) for (String c : dc) cols.add(c); + r.add("dynamic_columns", cols); + org.gephi.timeline.api.TimelineModel tm = tc.getModel(); + if (tm != null) { + r.addProperty("timeline_enabled", tm.isEnabled()); + r.addProperty("has_valid_bounds", tm.hasValidBounds()); + if (tm.hasValidBounds()) { + r.addProperty("interval_start", tm.getIntervalStart()); + r.addProperty("interval_end", tm.getIntervalEnd()); + } + } + } catch (Exception e) { r.addProperty("timeline_error", e.getMessage()); } + } else { + r.addProperty("timeline_controller", "unavailable"); + } + return r; + } + + // REMOVED: setTimeWindow. Driving Gephi's timeline from outside wedges the + // EDT two different ways — a time-derived setVisibleView deadlocks the + // renderer, and even setInterval/setEnabled saturates the EDT after one call. + // Because Gephi's own shutdown runs on the EDT, a wedged timeline op makes + // the app impossible to quit normally (Force Quit only). getTimeline + // (read-only, above) is safe and kept; any future write path must go through + // the viz-engine render-pause and off the EDT before it can be revived. + + /** Create a boolean column flagging rows whose column value matches a regex. */ + public JsonObject createRegexColumn(String target, String columnId, String newColumnTitle, String regex) { + GraphModel gm = currentGraphModel(); + if (gm == null) return error("No workspace open"); + if (columnId == null || regex == null || newColumnTitle == null) + return error("Missing 'column', 'regex', or 'new_column'"); + org.gephi.datalab.api.AttributeColumnsController acc = + Lookup.getDefault().lookup(org.gephi.datalab.api.AttributeColumnsController.class); + if (acc == null) return error("No datalab controller available"); + Table table = tableFor(gm, target); + Column col = table.getColumn(columnId); + if (col == null) return error("Column not found: " + columnId); + try { + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex); + Column created = acc.createBooleanMatchesColumn(table, col, newColumnTitle, pattern); + JsonObject r = success("Created boolean match column: " + newColumnTitle); + r.addProperty("column", created != null ? created.getId() : newColumnTitle); + return r; + } catch (java.util.regex.PatternSyntaxException e) { + return error("Invalid regex: " + e.getMessage()); + } catch (Exception e) { + return error("Create match column failed: " + e.getMessage()); + } + } + } diff --git a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java index dcf2b5623..dff6f1de0 100644 --- a/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java +++ b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java @@ -227,4 +227,105 @@ void buildCsvQuotesFieldsContainingSeparator() { assertEquals("Id,Label", lines[0]); assertEquals("a,\"Smith, John\"", lines[1]); } + + // ─── Data Laboratory cores (Group D) ───────────────────────────── + + @Test + void columnValueFrequenciesCountsPerValue() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of( + node("a", "team", "red"), node("b", "team", "red"), + node("c", "team", "blue"), node("d", "team", "red"))); + + JsonObject r = GephiControlService.columnValueFrequenciesCore(gm, "node", "team"); + assertTrue(r.get("success").getAsBoolean()); + assertEquals(2, r.get("distinct_values").getAsInt()); + assertEquals(4, r.get("total").getAsInt()); + JsonObject freq = r.getAsJsonObject("frequencies"); + assertEquals(3, freq.get("red").getAsInt()); + assertEquals(1, freq.get("blue").getAsInt()); + } + + @Test + void columnValueFrequenciesErrorsOnMissingColumn() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of(node("a"))); + JsonObject r = GephiControlService.columnValueFrequenciesCore(gm, "node", "nope"); + assertFalse(r.get("success").getAsBoolean()); + } + + @Test + void detectDuplicatesGroupsSharedValues() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of( + node("a", "email", "x@y.com"), node("b", "email", "x@y.com"), + node("c", "email", "z@y.com"), node("d", "email", "x@y.com"))); + + JsonObject r = GephiControlService.detectDuplicatesCore(gm, "node", "email", true); + assertTrue(r.get("success").getAsBoolean()); + assertEquals(1, r.get("group_count").getAsInt()); // only x@y.com is duplicated + assertEquals(3, r.getAsJsonArray("duplicate_groups").get(0).getAsJsonArray().size()); + } + + // ─── Typed parallel edges (Group F) ────────────────────────────── + + private static GraphModel modelWithNodes(String... ids) { + GraphModel gm = newModel(); + java.util.List> ns = new java.util.ArrayList<>(); + for (String id : ids) ns.add(node(id)); + GephiControlService.addNodesToModel(gm, ns); + return gm; + } + + @Test + void untypedDuplicateEdgeStillBlocked() { + // Regression: the pre-existing single-edge-per-pair rule must be unchanged + // when no edge_type is given. + GraphModel gm = modelWithNodes("a", "b"); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, null).get("success").getAsBoolean()); + assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, null).get("success").getAsBoolean()); + assertEquals(1, gm.getGraph().getEdgeCount()); + } + + @Test + void differentTypedEdgesCoexistBetweenSamePair() { + GraphModel gm = modelWithNodes("a", "b"); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean()); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "coauthor").get("success").getAsBoolean()); + assertEquals(2, gm.getGraph().getEdgeCount(), "two typed parallel edges should coexist"); + assertTrue(gm.getEdgeTypeCount() >= 2); + } + + @Test + void sameTypedEdgeIsStillBlocked() { + GraphModel gm = modelWithNodes("a", "b"); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean()); + assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean(), + "a second edge of the SAME type between the same pair is still a duplicate"); + assertEquals(1, gm.getGraph().getEdgeCount()); + } + + @Test + void batchAddHonorsPerEdgeType() { + GraphModel gm = modelWithNodes("a", "b"); + Map e1 = new LinkedHashMap<>(); + e1.put("source", "a"); e1.put("target", "b"); e1.put("edge_type", "cites"); + Map e2 = new LinkedHashMap<>(); + e2.put("source", "a"); e2.put("target", "b"); e2.put("edge_type", "coauthor"); + JsonObject r = GephiControlService.addEdgesToModel(gm, List.of(e1, e2)); + assertEquals(2, r.get("added").getAsInt()); + assertEquals(2, gm.getGraph().getEdgeCount()); + } + + @Test + void detectDuplicatesRespectsCaseInsensitivity() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of( + node("a", "name", "Alice"), node("b", "name", "alice"))); + + assertEquals(0, GephiControlService.detectDuplicatesCore(gm, "node", "name", true) + .get("group_count").getAsInt()); // case-sensitive: distinct + assertEquals(1, GephiControlService.detectDuplicatesCore(gm, "node", "name", false) + .get("group_count").getAsInt()); // case-insensitive: same + } }