diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml new file mode 100644 index 000000000..ff09cccbd --- /dev/null +++ b/modules/GephiMcp/pom.xml @@ -0,0 +1,142 @@ + + + 4.0.0 + + + org.gephi + gephi-plugin-parent + 0.11.1 + + + org.gephi.plugins + gephi-mcp + 1.2.3 + nbm + + Gephi AI (MCP) + 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 + utils-longtask + + + org.gephi + appearance-api + + + org.gephi + preview-api + + + org.gephi + filters-api + + + org.gephi + visualization-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..22cbabdaf --- /dev/null +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -0,0 +1,672 @@ +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.2.3"); + 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)) { + 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/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(); + } + + 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)) { + // 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()); + } + + 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..f3053e845 --- /dev/null +++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -0,0 +1,2753 @@ +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); } + } + // 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 { + 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]; + } + + 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) { + 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; + } + } + + /** 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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(); + lockRead(g); + try { + JsonArray arr = new JsonArray(); + int count = 0, skip = 0; + // 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(); + 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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(); + lockRead(g); + try { + JsonArray arr = new JsonArray(); + int count = 0, skip = 0; + // 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(); + 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(); + lockRead(g); + 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 { unlockWrite(g); } + 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(g); } + } 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 { unlockWrite(graph); } + } 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 { unlockWrite(graph); } + } 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 { unlockWrite(graph); } + } 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 { unlockWrite(graph); } + } 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().toArray()) { + n.setColor(defaultColor); + n.setSize(size); + } + } finally { unlockWrite(graph); } + 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().toArray()) { + Object v = n.getAttribute(col); + if (v != null) { + Color c = palette.get(v.toString()); + if (c != null) { + n.setColor(c); + colored++; + } + } + } + } finally { unlockWrite(graph); } + 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; + lockRead(g); + try { + for (Node n : g.getNodes().toArray()) { + 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}; + } + + /** + * 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(); + if (ws == null) return error("No project open"); + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + 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); + 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().toArray()) { + 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 { unlockWrite(graph); } + 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 = 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); + 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().toArray()) { + 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 { unlockWrite(graph); } + 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 ────────────────────────────────────────────────── + + /** + * 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(); + 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()); + } + } + + // 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); + + // 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 = 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); + 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 { unlockWrite(g); } + 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 { unlockWrite(g); } + 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) { + // 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(); + 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()); } + }); + } + + /** 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(); + 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"); + lockRead(g); + try { + 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()) { + 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"); + lockRead(g); + try { + 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)); + 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 { unlockWrite(g); } + } 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 { 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); + 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).toArray()) { + 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 { 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); + + 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 { unlockWrite(g); } + 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 { unlockWrite(g); } + 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(); + } + + /** + * 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/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..572cad87a --- /dev/null +++ b/modules/GephiMcp/src/main/resources/org/gephi/plugins/mcp/Bundle.properties @@ -0,0 +1,4 @@ +OpenIDE-Module-Name=Gephi AI (MCP) +OpenIDE-Module-Display-Category=Tool +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 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..dcf2b5623 --- /dev/null +++ b/modules/GephiMcp/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java @@ -0,0 +1,230 @@ +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"); + } + + /** + * 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(); + 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 11572dc3e..6f0747c95 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ + modules/GephiMcp