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 did not come from a browser.
+ *
+ * The Host check below stops DNS rebinding, but not a page the user is merely visiting
+ * calling {@code fetch("http://127.0.0.1:8080/...", {mode:"no-cors"})}. That request carries
+ * a loopback Host and a CORS-safelisted content type, so it passes every other check, and
+ * although CORS hides the reply the side effect has already happened. Destructive endpoints
+ * and the export endpoints, which write to a caller-supplied path, are reachable that way.
+ *
+ *
Browsers set {@code Origin} on such a request and {@code Sec-Fetch-Site} on every
+ * request, and page JavaScript can neither forge nor suppress them. Legitimate clients here
+ * are local processes, which send neither, so refusing on either header costs nothing and
+ * closes the hole. Kept package-private and static so it is unit-testable without a server.
+ */
+ static boolean isNonBrowserRequest(String origin, String secFetchSite) {
+ boolean hasOrigin = origin != null && !origin.trim().isEmpty();
+ boolean hasFetchSite = secFetchSite != null && !secFetchSite.trim().isEmpty();
+ return !hasOrigin && !hasFetchSite;
+ }
+
+ /** 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 AI API");
+ result.addProperty("version", moduleVersion());
+ 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());
+ // No side effects here: /health is a pure liveness probe that clients may
+ // poll at any frequency. The node click listener is installed once at
+ // plugin startup by Installer, as soon as the visualization controller is
+ // available, and again defensively by /selection (GephiControlService.
+ // getSelection calls ensureClickListener itself).
+ return result;
+ }
+
+ // ─── Human selection journal ─────────────────────────────────
+
+ if ("/selection".equals(uri) && Method.GET.equals(method)) {
+ // Default false, matching the Python tool: peeking must not consume.
+ boolean clear = "true".equalsIgnoreCase(params.get("clear"));
+ return service.getSelection(clear);
+ }
+
+ // ─── View / camera (teaching mode) ───────────────────────────
+
+ if ("/view/focus".equals(uri) && Method.POST.equals(method)) {
+ 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);
+ }
+
+ if ("/view/selection".equals(uri) && Method.POST.equals(method)) {
+ String mode = body != null && body.has("mode") ? body.get("mode").getAsString() : "rectangle";
+ return service.setSelectionMode(mode);
+ }
+
+ if ("/perspective".equals(uri) && Method.GET.equals(method)) {
+ return service.getPerspective();
+ }
+
+ if ("/perspective/switch".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("name")) return errorResult("Missing 'name'");
+ return service.switchPerspective(body.get("name").getAsString());
+ }
+
+ // ─── Project ─────────────────────────────────────────────────
+
+ if ("/project/new".equals(uri) && Method.POST.equals(method)) {
+ 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)) {
+ // The workspace index arrives as a query parameter (?index=N). Request
+ // bodies are parsed for POST and PUT only, so a JSON body on this DELETE
+ // was never readable; the query parameter is the supported form.
+ int index = parseIntParam(params.get("index"), -1);
+ if (index < 0) return errorResult("Missing 'index' query parameter");
+ 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, visibleParam(params, false));
+ }
+
+ 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();
+ String edgeType = body.has("edge_type") ? body.get("edge_type").getAsString() : null;
+ return service.addEdge(source, target, weight, directed, edgeType);
+ }
+
+ if ("/graph/edges/add".equals(uri) && Method.POST.equals(method)) {
+ 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, visibleParam(params, false));
+ }
+
+ // ─── Graph Stats & Type ──────────────────────────────────────
+
+ if ("/graph/stats".equals(uri) && Method.GET.equals(method)) {
+ return service.getGraphStats(visibleParam(params, false));
+ }
+
+ 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/edge/partition-color".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("column")) return errorResult("Missing 'column'");
+ String column = body.get("column").getAsString();
+ Map colorMap = null;
+ if (body.has("colors") && body.get("colors").isJsonObject()) {
+ colorMap = new HashMap<>();
+ JsonObject colors = body.getAsJsonObject("colors");
+ for (String key : colors.keySet()) {
+ List rgb = GSON.fromJson(colors.get(key), List.class);
+ colorMap.put(key, new int[]{rgb.get(0).intValue(), rgb.get(1).intValue(), rgb.get(2).intValue()});
+ }
+ }
+ return service.colorEdgesByPartition(column, colorMap);
+ }
+
+ if ("/appearance/ranking/color".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("column")) return errorResult("Missing 'column'");
+ String column = body.get("column").getAsString();
+ 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;
+ // Inline properties: configure and run in one step.
+ if (body.has("properties") && body.get("properties").isJsonObject()) {
+ Map properties = GSON.fromJson(body.get("properties"), Map.class);
+ return service.runLayout(algo, iterations, properties);
+ }
+ 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();
+ }
+
+ if ("/filter/list".equals(uri) && Method.GET.equals(method)) {
+ return service.listFilters();
+ }
+
+ if ("/filter/apply".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("name")) return errorResult("Missing 'name'");
+ String fname = body.get("name").getAsString();
+ Map filterParams = body.has("params") ? GSON.fromJson(body.get("params"), Map.class) : null;
+ String action = body.has("action") ? body.get("action").getAsString() : "select";
+ String column = body.has("column") ? body.get("column").getAsString() : null;
+ return service.applyFilter(fname, filterParams, action, column);
+ }
+
+ // ─── Data Laboratory ─────────────────────────────────────────
+
+ if ("/datalab/frequencies".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("column")) return errorResult("Missing 'column'");
+ String target = body.has("target") ? body.get("target").getAsString() : "node";
+ return service.columnValueFrequencies(target, body.get("column").getAsString());
+ }
+
+ if ("/datalab/duplicates".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("column")) return errorResult("Missing 'column'");
+ String target = body.has("target") ? body.get("target").getAsString() : "node";
+ boolean cs = body.has("case_sensitive") && body.get("case_sensitive").getAsBoolean();
+ return service.detectDuplicates(target, body.get("column").getAsString(), cs);
+ }
+
+ if ("/datalab/merge-nodes".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("ids")) return errorResult("Missing 'ids'");
+ java.util.List ids = GSON.fromJson(body.get("ids"), java.util.List.class);
+ String into = body.has("into") ? body.get("into").getAsString() : null;
+ return service.mergeNodes(ids, into);
+ }
+
+ if ("/datalab/regex-column".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("column") || !body.has("new_column") || !body.has("regex"))
+ return errorResult("Missing 'column', 'new_column', or 'regex'");
+ String target = body.has("target") ? body.get("target").getAsString() : "node";
+ return service.createRegexColumn(target, body.get("column").getAsString(),
+ body.get("new_column").getAsString(), body.get("regex").getAsString());
+ }
+
+ // ─── Timeline (read-only) ────────────────────────────────────
+ // NOTE: there is deliberately NO write endpoint here. Driving Gephi's
+ // timeline from outside (setInterval/setEnabled, or a time-derived
+ // setVisibleView) wedges the EDT, and Gephi's own shutdown runs on the
+ // EDT — so a wedged timeline op makes the app impossible to quit
+ // normally (Force Quit only). getTimeline is a pure read and is safe.
+
+ if ("/timeline".equals(uri) && Method.GET.equals(method)) {
+ return service.getTimeline();
+ }
+
+ // ─── Edge Appearance ────────────────────────────────────────
+
+ if ("/appearance/edge/thickness-by-weight".equals(uri) && Method.POST.equals(method)) {
+ 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");
+ // Body shape is flat {property: value}; unwrap the common client
+ // mistake of nesting everything under a "settings" key so it does
+ // not get stored as a junk preview property named "settings".
+ JsonObject effective = body;
+ if (body.size() == 1 && body.has("settings") && body.get("settings").isJsonObject()) {
+ effective = body.getAsJsonObject("settings");
+ }
+ Map settings = GSON.fromJson(effective, Map.class);
+ return service.setPreviewSettings(settings);
+ }
+
+ // ─── 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(visibleBody(body, true));
+ }
+ return service.exportGexf(body.get("file").getAsString(), visibleBody(body, true));
+ }
+
+ if ("/export/format".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("file") || !body.has("format"))
+ return errorResult("Missing 'file' or 'format'");
+ return service.exportByFormat(body.get("file").getAsString(), body.get("format").getAsString(), visibleBody(body, true));
+ }
+
+ 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/screenshot".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("file")) return errorResult("Missing 'file'");
+ String file = body.get("file").getAsString();
+ int scale = body.has("scale") ? body.get("scale").getAsInt() : 2;
+ boolean transparent = body.has("transparent_background")
+ && body.get("transparent_background").getAsBoolean();
+ return service.exportScreenshot(file, scale, transparent);
+ }
+
+ 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(), visibleBody(body, true));
+ }
+
+ 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(), floatOrNull(body, "max_node_size"));
+ }
+
+ 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(), floatOrNull(body, "max_node_size"));
+ }
+
+ 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(), floatOrNull(body, "max_node_size"));
+ }
+
+ 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(), floatOrNull(body, "max_node_size"));
+ }
+
+ return errorResult("Unknown endpoint: " + method + " " + uri);
+ }
+
+ /**
+ * Reads an optional {@code visible} query parameter. When absent the endpoint keeps the
+ * view it has always used, so wiring this changes no existing caller. Every affected
+ * response also states which view it used and whether a filter is active, so the two
+ * can no longer disagree silently.
+ */
+ static boolean visibleParam(Map params, boolean dflt) {
+ String v = params == null ? null : params.get("visible");
+ if (v == null) return dflt;
+ String t = v.trim();
+ if (t.isEmpty()) return dflt;
+ if ("true".equalsIgnoreCase(t) || "1".equals(t)) return true;
+ if ("false".equalsIgnoreCase(t) || "0".equals(t)) return false;
+ // Anything else is not a decision. Falling back to the endpoint's default beats
+ // reading unrecognised text as "false", which would silently switch an export
+ // from the filtered graph to the whole graph on a typo.
+ return dflt;
+ }
+
+ /** The {@code visible} flag from a JSON body, defaulting to the endpoint's historical view. */
+ static boolean visibleBody(JsonObject body, boolean dflt) {
+ if (body == null || !body.has("visible") || body.get("visible").isJsonNull()) return dflt;
+ com.google.gson.JsonElement e = body.get("visible");
+ // Only a real JSON boolean decides. Gson would read the string "banana" as false,
+ // which is the same silent-switch trap the query parameter avoids above.
+ if (e.isJsonPrimitive() && e.getAsJsonPrimitive().isBoolean()) {
+ return e.getAsBoolean();
+ }
+ return dflt;
+ }
+
+ /**
+ * The module's own version, read from the manifest the build generates from the POM.
+ * A literal here drifts from the POM the first time someone bumps one and not the other,
+ * and the drift is invisible until a user reports the wrong version.
+ */
+ static String moduleVersion() {
+ try {
+ org.openide.modules.ModuleInfo info =
+ org.openide.modules.Modules.getDefault().ownerOf(GephiAPIServer.class);
+ if (info != null && info.getSpecificationVersion() != null) {
+ return info.getSpecificationVersion().toString();
+ }
+ } catch (Throwable t) {
+ // Not running inside the platform (unit tests, or a future API change).
+ }
+ String fromPackage = GephiAPIServer.class.getPackage() == null
+ ? null : GephiAPIServer.class.getPackage().getImplementationVersion();
+ return fromPackage != null ? fromPackage : "unknown";
+ }
+
+ /** An optional float from a JSON body, or null when absent or not a number. */
+ static Float floatOrNull(JsonObject body, String key) {
+ if (body == null || !body.has(key) || body.get(key).isJsonNull()) return null;
+ try {
+ return body.get(key).getAsFloat();
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ 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 AI API started on http://127.0.0.1:" + getListeningPort());
+ }
+
+ public void stopServer() {
+ stop();
+ service.shutdown();
+ LOGGER.info("Gephi AI API stopped");
+ }
+}
diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java
new file mode 100644
index 000000000..9c0efee9e
--- /dev/null
+++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java
@@ -0,0 +1,4009 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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 javax.imageio.ImageIO;
+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.graph.api.Column;
+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.Exporter;
+import org.gephi.io.exporter.spi.GraphExporter;
+import org.gephi.filters.api.FilterController;
+import org.gephi.filters.api.Query;
+import org.gephi.filters.spi.CategoryBuilder;
+import org.gephi.filters.spi.Filter;
+import org.gephi.filters.spi.FilterBuilder;
+import org.gephi.filters.spi.FilterProperty;
+import org.gephi.io.importer.api.Container;
+import org.gephi.io.importer.api.ImportController;
+import org.gephi.io.processor.spi.Processor;
+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;
+ // Not final: shutdown() kills it, and the server can be stopped and restarted from
+ // Tools > Gephi AI Server without the service singleton being recreated. A final
+ // executor left every later layout failing with RejectedExecutionException for the
+ // rest of the session. Always reach it through layoutExecutor().
+ private ExecutorService layoutExecutor = Executors.newSingleThreadExecutor();
+ // Config staged by setLayoutProperties (configure-only); the next runLayout of
+ // the same algorithm applies it. Lets set-then-run work without setLayoutProperties
+ // itself starting a layout.
+ private volatile Map pendingLayoutProps = null;
+ private volatile String pendingLayoutAlgo = null;
+
+ // Human click journal: the person's node clicks in the Gephi window,
+ // recorded by a passive viz-event listener so the model can resolve
+ // "this one" / "these" to actual nodes. Bounded; strings only (never
+ // hold Node references — they outlive workspaces).
+ private static final int CLICK_JOURNAL_MAX = 50;
+ private final java.util.ArrayDeque clickJournal = new java.util.ArrayDeque<>();
+ private volatile boolean clickListenerInstalled = false;
+ // Rectangle selection is turned on once per session so the human can box-select
+ // nodes for the agent to read without hunting for the toolbar tool. Set only
+ // after it actually succeeds (the view may not be started at the first attempt).
+ private volatile boolean rectangleAutoEnabled = false;
+
+ 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");
+ } catch (Throwable t) {
+ // Any other failure here (e.g. a classloading Error, which is not an Exception
+ // and would otherwise skip every catch(Exception) up the call chain and kill the
+ // HTTP connection with no response) must still surface as a normal API error.
+ throw new RuntimeException("Could not acquire write lock: " + t, t);
+ } 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();
+ }
+ }
+
+ /**
+ * Preview refresh on the EDT — the one piece of the former runOnEDT bodies that
+ * belongs there (it touches Swing-backed preview state). Failures are logged, not
+ * surfaced: by the time this runs the graph mutation has already been applied, and
+ * returning an error for a cosmetic refresh would tell the client a destructive
+ * operation failed when it did not, inviting a double-apply retry.
+ */
+ private void refreshPreviewOnEDT(Workspace ws) {
+ try {
+ PreviewController pc = Lookup.getDefault().lookup(PreviewController.class);
+ if (pc != null) runOnEDT(() -> { pc.refreshPreview(ws); return null; });
+ } catch (RuntimeException e) {
+ LOGGER.log(Level.WARNING, "Preview refresh failed after graph mutation", e);
+ }
+ }
+
+ 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) and
+ * return a ready-to-use instance.
+ *
+ * A freshly built layout has its properties at Java zero-values, NOT at Gephi's
+ * defaults — those live in {@code resetPropertiesValues()}, which the Gephi UI calls
+ * when you select a layout and which nothing here used to call. Layouts whose builder
+ * self-initializes (ForceAtlas 2) were fine; the rest silently ran on zeros. OpenOrd
+ * with {@code Layout Size} 0 collapsed every node onto (0,0), and Yifan Hu with
+ * {@code optimalDistance}/{@code stepRatio} 0 was a complete no-op that still reported
+ * success. Reset here so every layout starts from Gephi's real defaults and callers
+ * only need to pass the properties they actually want to change.
+ *
+ *
The graph model is attached first because size-dependent defaults read it
+ * (ForceAtlas 2 picks scalingRatio 2.0 vs 10.0 off the node count).
+ */
+ 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);
+ if (idx < 0) return null;
+ Layout layout = builders.get(idx).buildLayout();
+ if (layout == null) return null;
+ // Separate failure paths: a missing graph model must not skip the reset, which is
+ // the part that actually keeps OpenOrd and Yifan Hu from running on zeros.
+ try {
+ GraphModel gm = currentGraphModel();
+ if (gm != null) layout.setGraphModel(gm);
+ } catch (Exception e) {
+ LOGGER.log(Level.WARNING, "setGraphModel failed for layout: " + algo, e);
+ }
+ try {
+ layout.resetPropertiesValues();
+ } catch (Exception e) {
+ // A layout that rejects the reset is still usable on its own defaults.
+ LOGGER.log(Level.WARNING, "resetPropertiesValues failed for layout: " + algo, e);
+ }
+ return layout;
+ }
+
+ /**
+ * 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) {
+ File file = new File(filePath);
+ if (!file.exists()) return error("File not found: " + filePath);
+ try {
+ ProjectController pc = getProjectController();
+ // Close any open project FIRST. Opening a .gephi on top of an existing
+ // project lands in a broken half-state where the graphstore never
+ // deserializes into a queryable model — the "open reports success but the
+ // graph is blank" bug. Verified: open works as the first action on a fresh
+ // instance and fails only when a project is already open; Gephi's own
+ // File>Open closes first. closeCurrentProject touches UI, so run it on EDT.
+ if (pc.hasCurrentProject()) {
+ runOnEDT(() -> { pc.closeCurrentProject(); return null; });
+ }
+ // openProject(File) off the EDT: it blocks on a LongTaskExecutor Future
+ // whose completion needs a free EDT.
+ pc.openProject(file);
+ } catch (Exception e) {
+ return error("Failed to open project: " + e.getMessage());
+ }
+ // Report the actual loaded counts so an empty result is never a silent success.
+ return runOnEDT(() -> {
+ JsonObject r = success("Project opened");
+ Workspace cur = getProjectController().getCurrentWorkspace();
+ int nodes = 0, edges = 0;
+ if (cur != null) {
+ Graph g = getGraphController().getGraphModel(cur).getGraph();
+ nodes = g.getNodeCount();
+ edges = g.getEdgeCount();
+ }
+ r.addProperty("node_count", nodes);
+ r.addProperty("edge_count", edges);
+ if (nodes == 0) r.addProperty("warning", "opened but no nodes are in the current workspace");
+ return r;
+ });
+ }
+
+ 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) {
+ return queryNodes(attr, val, limit, offset, false);
+ }
+
+ /** @param visible read the filtered visible graph instead of the full graph (see addViewInfo). */
+ public JsonObject queryNodes(String attr, String val, int limit, int offset, boolean visible) {
+ try {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ GraphModel gm = getGraphController().getGraphModel(ws);
+ Graph g = visible ? gm.getGraphVisible() : 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);
+ addViewInfo(r, gm, visible);
+ 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) {
+ return addEdge(src, tgt, weight, directed, null);
+ }
+
+ public JsonObject addEdge(String src, String tgt, Double weight, boolean directed, String edgeType) {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ return addEdgeToModel(getGraphController().getGraphModel(ws), src, tgt, weight, directed, edgeType);
+ }
+
+ /** Core edge-add against an explicit model. Type and directedness are kept consistent. */
+ static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight, boolean directed) {
+ return addEdgeToModel(gm, src, tgt, weight, directed, null);
+ }
+
+ /**
+ * Core edge-add, with an optional relationship type. When edgeType is null
+ * or blank the behavior is exactly as before: one edge per (source, target),
+ * type 0/1 by directedness. When edgeType is given, the edge is created under
+ * that named type (GraphStore's native typed parallel edges) and the
+ * duplicate check is scoped to that type — so A→B can carry a "cites" edge
+ * AND a "coauthor" edge at once, while a second "cites" A→B is still blocked.
+ */
+ static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight,
+ boolean directed, String edgeType) {
+ try {
+ Graph g = gm.getGraph();
+ lockWrite(g);
+ 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);
+ double w = weight != null ? weight : 1.0;
+ if (edgeType != null && !edgeType.isEmpty()) {
+ int typeId = gm.addEdgeType(edgeType);
+ if (g.getEdge(s, t, typeId) != null) return error("Edge of type '" + edgeType + "' exists");
+ g.addEdge(gm.factory().newEdge(s, t, typeId, w, directed));
+ } else {
+ if (findEdge(g, s, t) != null) return error("Edge exists");
+ g.addEdge(gm.factory().newEdge(s, t, directed ? 1 : 0, w, directed));
+ }
+ return success("Edge added");
+ } finally { unlockWrite(g); }
+ } catch (Exception e) { return error("Failed: " + e.getMessage()); }
+ }
+
+ 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) { 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"));
+ Object edgeTypeObj = ed.get("edge_type");
+ String edgeType = edgeTypeObj != null ? edgeTypeObj.toString() : null;
+ int type;
+ if (edgeType != null && !edgeType.isEmpty()) {
+ type = gm.addEdgeType(edgeType);
+ if (g.getEdge(s, t, type) != null) { skipped++; continue; }
+ } else {
+ if (findEdge(g, s, t) != null) { skipped++; continue; }
+ type = directed ? 1 : 0;
+ }
+ Edge e = gm.factory().newEdge(s, t, type, w, directed);
+ Object label = ed.get("label");
+ if (label != null) e.setLabel(label.toString());
+ g.addEdge(e);
+ 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) {
+ return queryEdges(limit, offset, false);
+ }
+
+ /** @param visible read the filtered visible graph instead of the full graph (see addViewInfo). */
+ public JsonObject queryEdges(int limit, int offset, boolean visible) {
+ try {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ GraphModel gm = getGraphController().getGraphModel(ws);
+ Graph g = visible ? gm.getGraphVisible() : 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);
+ addViewInfo(r, gm, visible);
+ r.add("edges", arr);
+ return r;
+ } finally { g.readUnlock(); }
+ } catch (Exception e) { return error("Failed: " + e.getMessage()); }
+ }
+
+ // ─── Graph Stats ─────────────────────────────────────────────────
+
+ /**
+ * Read/export view consistency. File and inline exports historically write the
+ * VISIBLE (filtered) graph while every read endpoint reads the FULL graph — so with
+ * a filter active, /graph/stats could report 5000 nodes while /export/gexf silently
+ * wrote 200, and every consumer of the inline GEXF computed over a graph the stats
+ * never described. The defaults are kept (changing them would silently change every
+ * existing client), but no response is silent about it any more: each one carries
+ * {@code view} ("full" | "visible") naming the view it was computed from and
+ * {@code filter_active}; whenever a filter IS active it also carries
+ * {@code full_node_count}/{@code full_edge_count} and
+ * {@code visible_node_count}/{@code visible_edge_count} so the discrepancy is
+ * visible to the caller. The {@code visible} overloads let the HTTP layer expose an
+ * explicit choice of view per request.
+ */
+ private static void addViewInfo(JsonObject r, GraphModel gm, boolean visibleView) {
+ boolean filterActive = !gm.getVisibleView().isMainView();
+ r.addProperty("view", visibleView ? "visible" : "full");
+ r.addProperty("filter_active", filterActive);
+ if (filterActive) {
+ Graph full = gm.getGraph();
+ Graph vis = gm.getGraphVisible();
+ r.addProperty("full_node_count", full.getNodeCount());
+ r.addProperty("full_edge_count", full.getEdgeCount());
+ r.addProperty("visible_node_count", vis.getNodeCount());
+ r.addProperty("visible_edge_count", vis.getEdgeCount());
+ }
+ }
+
+ public JsonObject getGraphStats() {
+ return getGraphStats(false);
+ }
+
+ /** @param visible read the filtered visible graph instead of the full graph (see addViewInfo). */
+ public JsonObject getGraphStats(boolean visible) {
+ try {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ GraphModel gm = getGraphController().getGraphModel(ws);
+ Graph g = visible ? gm.getGraphVisible() : 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());
+ addViewInfo(r, gm, visible);
+ 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()); }
+ }
+
+ /*
+ * THREADING NOTE (applies to every styling/filter method below that once wrapped its
+ * body in runOnEDT): these operations mutate the graph model, which is thread-safe
+ * under its own lock and does not need the EDT. Polling lockWrite's 15-second tryLock
+ * loop ON the EDT froze the UI under contention, tripped runOnEDT's own 15-second
+ * timeout (misreporting "Gephi's UI thread is unresponsive"), and — worse — the
+ * abandoned EDT task still ran later, applying a destructive mutation after the HTTP
+ * call had already reported failure, so a client retry applied it twice. They now run
+ * on the calling thread, like clearGraph and addNodeToModel always have. Only the
+ * preview refresh still hops to the EDT (refreshPreviewOnEDT).
+ */
+
+ public JsonObject setEdgeColor(String source, String target, int r, int g, int b, int a) {
+ 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) {
+ 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) {
+ 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.
+ *
+ * Must be called OFF the EDT: runStatistic executes the statistic (statistics
+ * dispatch UI work to the EDT internally — see extractGiantComponent) and renders
+ * its report, which for Degree is a JFreeChart image. colorByRanking and
+ * sizeByRanking call this from the HTTP thread, never inside a runOnEDT hop.
+ */
+ 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) {
+ 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) {
+ 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) {
+ return runLayout(algo, iterations, null);
+ }
+
+ public JsonObject runLayout(String algo, int iterations, Map properties) {
+ 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);
+ // Apply inline properties, or config staged earlier by setLayoutProperties.
+ if (properties == null && pendingLayoutProps != null && algo.equals(pendingLayoutAlgo)) {
+ properties = pendingLayoutProps;
+ }
+ pendingLayoutProps = null;
+ pendingLayoutAlgo = null;
+ if (properties != null) applyLayoutProperties(layout, properties);
+ final Layout fl = layout;
+ final int iters = iterations > 0 ? iterations : 1000;
+ if (!layoutRunning.compareAndSet(false, true)) return error("Layout already running");
+ currentLayoutName = algo;
+ try {
+ layoutFuture = layoutExecutor().submit(() -> {
+ try {
+ fl.initAlgo();
+ for (int i = 0; i < iters && layoutRunning.get() && fl.canAlgo(); i++) fl.goAlgo();
+ } catch (Exception e) { LOGGER.log(Level.WARNING, "Layout error", e); }
+ finally {
+ // endAlgo() is where Gephi layouts release the graph model and their
+ // column observers — it must run even when goAlgo() throws, or those
+ // leak for the life of the workspace.
+ try { fl.endAlgo(); }
+ catch (Exception e) { LOGGER.log(Level.WARNING, "Layout endAlgo error", e); }
+ layoutRunning.set(false);
+ currentLayoutName = null;
+ }
+ });
+ } catch (RuntimeException submitFailure) {
+ // RejectedExecutionException (executor already shut down): without this reset
+ // the flag stays true and every later run reports "Layout already running"
+ // for the rest of the session.
+ layoutRunning.set(false);
+ currentLayoutName = null;
+ throw submitFailure;
+ }
+ 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);
+ // cancel(false): the cooperative layoutRunning flag already stops the loop at the
+ // next iteration. Interrupting instead (cancel(true)) can throw InterruptedException
+ // out of goAlgo() mid-iteration (OpenOrd synchronizes worker threads on a barrier),
+ // and a layout that took a read lock without a finally then leaks it permanently.
+ if (layoutFuture != null) layoutFuture.cancel(false);
+ 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()); }
+ }
+
+ /** Match each Layout property against a caller-supplied key map and set it. */
+ private void applyLayoutProperties(Layout layout, Map properties) {
+ if (properties == null) return;
+ LayoutProperty[] props = layout.getProperties();
+ if (props == null) return;
+ 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) {
+ try { prop.getProperty().setValue(converted); }
+ catch (Exception e) { LOGGER.log(Level.WARNING, "Set layout property failed", e); }
+ }
+ }
+ }
+ }
+
+ /**
+ * Configure a layout's properties WITHOUT running it. The config is staged so
+ * the next runLayout of the same algorithm applies it — set-then-run works,
+ * and this call no longer hijacks the layout executor (which broke a following
+ * run_layout with "Layout already running"). Prefer run_layout(properties=...)
+ * to configure and run in one step.
+ */
+ public JsonObject setLayoutProperties(String algo, Map properties, int iterations) {
+ try {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ Layout layout = findLayout(algo);
+ if (layout == null) return error("Layout not found: " + algo);
+ layout.setGraphModel(currentGraphModel());
+ applyLayoutProperties(layout, properties);
+ pendingLayoutProps = properties;
+ pendingLayoutAlgo = algo;
+ JsonObject r = new JsonObject();
+ r.addProperty("success", true);
+ r.addProperty("layout", algo);
+ r.addProperty("configured", true);
+ r.addProperty("running", false);
+ r.addProperty("note", "properties staged; the next run_layout of this algorithm applies them");
+ 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; collect the ones that did not land so a
+ // mistyped name is reported instead of silently ignored (a typo used to be
+ // indistinguishable from a correctly-parameterised run).
+ java.util.List unappliedParams = new java.util.ArrayList<>();
+ if (params != null) {
+ for (Map.Entry e : params.entrySet()) {
+ if (!setViaReflection(stat, e.getKey(), e.getValue())) unappliedParams.add(e.getKey());
+ }
+ }
+
+ // 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());
+ if (!unappliedParams.isEmpty()) {
+ JsonArray ua = new JsonArray();
+ for (String k : unappliedParams) ua.add(k);
+ r.add("unapplied_params", ua);
+ r.addProperty("warning", "Parameters matched no setter or field on "
+ + stat.getClass().getSimpleName() + " and were NOT applied: " + unappliedParams);
+ }
+
+ // 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());
+ }
+ }
+
+ /**
+ * Set {@code setter} on {@code obj} via a JavaBeans setter or, failing that, a bare
+ * field of the same (case-insensitive) name. Returns true only when a value was
+ * actually applied; callers surface the false case so a mistyped parameter name is
+ * distinguishable from a correctly-configured run.
+ */
+ private boolean 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) return false; // name matched, value did not convert
+ m.invoke(obj, converted);
+ return true;
+ }
+ }
+ // 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) return false;
+ f.setAccessible(true);
+ f.set(obj, converted);
+ return true;
+ }
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.fine("Could not set " + methodName + ": " + e.getMessage());
+ }
+ return false;
+ }
+
+ /** 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) {
+ 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); }
+ refreshPreviewOnEDT(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) {
+ 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); }
+ refreshPreviewOnEDT(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: set on the preview model under Gephi's canonical key
+ // (PreviewProperty.BACKGROUND_COLOR) so the Preview panel, the renderers,
+ // and exportPng's export-time read all share one source of truth. The old
+ // cached exportBackgroundColor field was process-wide sticky state: once
+ // set it tinted every later export in every workspace and project, even
+ // after the user changed the background in Gephi's own Preview panel.
+ 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));
+ PreviewProperty bgProp = pm.getProperties().getProperty(PreviewProperty.BACKGROUND_COLOR);
+ if (bgProp != null) {
+ bgProp.setValue(bgColor);
+ } else {
+ pm.getProperties().putValue(PreviewProperty.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. Non-scalar values are
+ // never valid preview properties — storing one corrupts the
+ // model, so skip them.
+ if (val instanceof Map || val instanceof List) {
+ LOGGER.warning("MCP: Skipping non-scalar preview value for " + key);
+ continue;
+ }
+ Object coerced = val;
+ if (val instanceof String) {
+ String sv = ((String) val).trim();
+ 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 exportGexf(filePath, true);
+ }
+
+ /**
+ * @param visible export the filtered visible graph (true — the historical behaviour)
+ * or the full graph. Either way the response self-declares which view was
+ * written via addViewInfo, so a filtered export is never silent about it.
+ */
+ public JsonObject exportGexf(String filePath, boolean visible) {
+ 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(visible);
+ ((GraphExporter) exporter).setWorkspace(ws);
+ }
+ ec.exportFile(new File(filePath), exporter);
+ JsonObject r = success("Exported to " + filePath);
+ addViewInfo(r, currentGraphModel(), visible);
+ return r;
+ } catch (Exception e) { return error("Export failed: " + e.getMessage()); }
+ });
+ }
+
+ /** GEXF export returned inline as a string — no file round-trip. */
+ public JsonObject exportGexfContent() {
+ return exportGexfContent(true);
+ }
+
+ /**
+ * @param visible export the filtered visible graph (true — the historical behaviour)
+ * or the full graph. Several downstream tools parse this inline GEXF as their
+ * read path, so the response self-declares the view via addViewInfo: with a
+ * filter active they would otherwise silently compute over a subgraph the
+ * read endpoints never described.
+ */
+ public JsonObject exportGexfContent(boolean visible) {
+ 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(visible);
+ ((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");
+ addViewInfo(r, currentGraphModel(), visible);
+ 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) {
+ // Runs on the calling thread: rendering the export and compositing the
+ // background below (ImageIO.read, a full BufferedImage copy, ImageIO.write —
+ // at a default 1920x1080) is far too heavy for the EDT and needs nothing from
+ // it. Only the preview refresh hops to the EDT.
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ try {
+ refreshPreviewOnEDT(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 the preview model's background color.
+ // Gephi's PNG exporter renders a transparent background; this fills it.
+ // The color is read from the preview model AT EXPORT TIME, so a change in
+ // Gephi's own Preview panel (or another workspace's settings) is honoured
+ // rather than overridden by a stale process-wide copy.
+ Color bgColor = previewBackgroundColor(ws);
+ 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()); }
+ }
+
+ /** The workspace's preview background color, or null when none is available. */
+ private static Color previewBackgroundColor(Workspace ws) {
+ PreviewController pc = Lookup.getDefault().lookup(PreviewController.class);
+ PreviewModel pm = pc != null ? pc.getModel(ws) : null;
+ if (pm == null) return null;
+ Object bg = pm.getProperties().getValue(PreviewProperty.BACKGROUND_COLOR);
+ // Legacy spelling: earlier plugin builds stored the color under "background.color".
+ if (!(bg instanceof Color)) bg = pm.getProperties().getValue("background.color");
+ return bg instanceof Color ? (Color) bg : null;
+ }
+
+ /**
+ * Export the LIVE Overview canvas as it is actually rendered on screen — selection
+ * highlighting, hover state, current camera framing — using Gephi's own built-in
+ * screenshot feature (org.gephi.visualization.api.ScreenshotController), the same
+ * backend behind the toolbar "take a snapshot" button. This is a DIFFERENT pipeline
+ * from exportPng: exportPng renders the graph's stored data (colors, positions) through
+ * the Preview renderer, which has no concept of selection at all. This method captures
+ * the actual GL framebuffer, so a person's box-drag selection (dimmed unselected nodes,
+ * vivid selected ones) shows up exactly as they see it.
+ *
+ * scaleFactor: a multiplier on the current on-screen canvas size (not literal pixel
+ * width/height like exportPng — Gephi's screenshot API only supports a scale factor).
+ *
+ * takeScreenshot() is asynchronous (queued against the render engine's next frame via
+ * a LongTaskExecutor), so this polls a dedicated fresh temp directory for the resulting
+ * file rather than assuming completion on return.
+ */
+ public JsonObject exportScreenshot(String filePath, int scaleFactor, boolean transparentBackground) {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+
+ File targetFile = new File(filePath);
+ File targetDir = targetFile.getAbsoluteFile().getParentFile();
+ File captureDir;
+ try {
+ captureDir = java.nio.file.Files.createTempDirectory("gephi-screenshot-").toFile();
+ } catch (java.io.IOException e) {
+ return error("Could not create temp capture directory: " + e.getMessage());
+ }
+
+ try {
+ runOnEDT(() -> {
+ // ScreenshotController is not independently registered in Lookup — it is only
+ // reachable via VisualizationController.getScreenshotController() (the same
+ // VisualizationController singleton getSelection/focusView already use).
+ org.gephi.visualization.api.VisualizationController vc = Lookup.getDefault()
+ .lookup(org.gephi.visualization.api.VisualizationController.class);
+ if (vc == null) throw new RuntimeException("Visualization controller not available");
+ org.gephi.visualization.api.ScreenshotController sc = vc.getScreenshotController();
+ if (sc == null) throw new RuntimeException("Screenshot controller not available");
+ // These four settings are shared with Gephi's own toolbar screenshot button,
+ // and ScreenshotController exposes setters only, so their previous values
+ // cannot be read back and restored exactly. What must not happen is leaving
+ // auto-save enabled while pointing at captureDir, which this method deletes:
+ // the user's next manual screenshot would then save into a directory that no
+ // longer exists. Auto-save is therefore turned back off and the directory
+ // pointed somewhere real, which returns the toolbar button to its normal
+ // save-dialog behaviour rather than to a silent failure.
+ try {
+ sc.setAutoSave(true);
+ sc.setDefaultDirectory(captureDir);
+ sc.setScaleFactor(scaleFactor);
+ sc.setTransparentBackground(transparentBackground);
+ sc.takeScreenshot();
+ } finally {
+ sc.setAutoSave(false);
+ sc.setDefaultDirectory(new File(System.getProperty("user.home")));
+ }
+ return null;
+ });
+
+ File written = pollForNewFile(captureDir, 10_000);
+ if (written == null) {
+ return error("Screenshot did not complete within 10s — the render engine may be busy, "
+ + "retry, or fully restart Gephi if this persists");
+ }
+ if (!waitForStableFileSize(written, 5_000)) {
+ return error("Screenshot file did not finish writing within 5s");
+ }
+
+ if (targetDir != null) targetDir.mkdirs();
+ java.nio.file.Files.move(written.toPath(), targetFile.toPath(),
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+
+ JsonObject r = success("Exported to " + filePath);
+ r.addProperty("scale_factor", scaleFactor);
+ r.addProperty("selection_aware", true);
+ return r;
+ } catch (Exception e) {
+ return error("Screenshot export failed: " + e.getMessage());
+ } finally {
+ deleteDirQuietly(captureDir);
+ }
+ }
+
+ /** Poll a directory for the first file to appear in it, up to timeoutMs. */
+ static File pollForNewFile(File dir, long timeoutMs) {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ File[] files = dir.listFiles();
+ if (files != null && files.length > 0) return files[0];
+ try { Thread.sleep(150); } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /** Wait for a file's size to stop changing between polls (write-in-progress guard). */
+ static boolean waitForStableFileSize(File file, long timeoutMs) {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ long lastSize = -1;
+ while (System.currentTimeMillis() < deadline) {
+ long size = file.length();
+ if (size > 0 && size == lastSize) return true;
+ lastSize = size;
+ try { Thread.sleep(100); } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ return file.length() == lastSize && lastSize > 0;
+ }
+
+ static void deleteDirQuietly(File dir) {
+ File[] files = dir.listFiles();
+ if (files != null) for (File f : files) f.delete();
+ dir.delete();
+ }
+
+ /**
+ * Poll the live engine selection until its size matches expected or timeoutMs
+ * elapses. selectNodes()/resetSelection() queue their effect onto the render
+ * engine rather than applying it synchronously with the call, so a read (or a
+ * screenshot) taken immediately after can race ahead of it and see stale state.
+ * Returns the final observed size (may differ from expected on timeout).
+ */
+ private static int waitForSelectionCount(
+ org.gephi.visualization.api.VisualizationController vc, int expected, long timeoutMs) {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ int last = -1;
+ while (System.currentTimeMillis() < deadline) {
+ org.gephi.visualization.api.VisualizationModel model = vc.getModel();
+ last = model != null ? model.getSelectedNodes().size() : 0;
+ if (last == expected) return last;
+ try { Thread.sleep(30); } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return last;
+ }
+ }
+ return last;
+ }
+
+ 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 exportGraphml(filePath, true);
+ }
+
+ /** @param visible see exportGexf — same contract, response self-declares the view. */
+ public JsonObject exportGraphml(String filePath, boolean visible) {
+ 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(visible);
+ ((GraphExporter) exporter).setWorkspace(ws);
+ }
+ ec.exportFile(new File(filePath), exporter);
+ JsonObject r = success("Exported to " + filePath);
+ addViewInfo(r, currentGraphModel(), visible);
+ return r;
+ } catch (Exception e) { return error("Export failed: " + e.getMessage()); }
+ });
+ }
+
+ public JsonObject exportCsv(String filePath, String separator, String target) {
+ // Runs on the calling thread: serialising the whole graph into a StringBuilder
+ // and writing it to disk is bulk work with no Swing dependency — it has no
+ // business on the EDT (see the threading note above setEdgeColor).
+ 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 {
+ GraphModel gm = currentGraphModel();
+ String csvText = buildCsv(gm, 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);
+ }
+ JsonObject r = success("Exported to " + filePath);
+ // CSV is built from the FULL graph (buildCsv walks gm.getGraph()) — declare
+ // that, since the other exporters write the visible graph.
+ addViewInfo(r, gm, false);
+ return r;
+ } 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 importFile(filePath, null);
+ }
+
+ /**
+ * Imports a file. {@code maxNodeSize} caps imported node sizes when set; when null the
+ * file's own sizes are preserved exactly, so an import followed by an export round-trips.
+ */
+ public JsonObject importFile(String filePath, Float maxNodeSize) {
+ // Runs on the calling thread. Gephi's own import runs off the event dispatch thread,
+ // and parsing a large file inside runOnEDT froze the UI and then blew its 15-second
+ // budget, so the caller was told "Gephi's UI thread is unresponsive, fully quit and
+ // reopen" while the import was in fact still running and went on to succeed.
+ {
+ 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);
+
+ // Optional, and off by default. Capping rewrites viz:size values the file
+ // actually carries, so importing and re-exporting would silently change the
+ // user's data. It stays available because oversized nodes from GEXF can hide
+ // the whole graph, but only when the caller asks for it.
+ int capped = 0;
+ if (maxNodeSize != null && maxNodeSize > 0) {
+ Graph importedGraph = getGraphController().getGraphModel(ws).getGraph();
+ lockWrite(importedGraph);
+ try {
+ for (Node n : importedGraph.getNodes().toArray()) {
+ if (n.size() > maxNodeSize) {
+ n.setSize(maxNodeSize.floatValue());
+ capped++;
+ }
+ }
+ } finally {
+ unlockWrite(importedGraph);
+ }
+ }
+
+ Workspace effectiveWs = importedWs != null ? importedWs : ws;
+ Graph g = getGraphController().getGraphModel(effectiveWs).getGraph();
+ JsonObject r = success("Imported from " + file.getName());
+ if (capped > 0) {
+ r.addProperty("nodes_size_capped", capped);
+ r.addProperty("max_node_size", maxNodeSize);
+ }
+ 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() {
+ 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 (EDT hop; outside the lock)
+ refreshPreviewOnEDT(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) {
+ 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 (EDT hop; outside the lock)
+ refreshPreviewOnEDT(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 the calling thread — graph mutation needs only the graph
+ // write lock (see the threading note above setEdgeColor); only the preview
+ // refresh hops to the EDT.
+ 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 != giantComp) toRemove.add(n);
+ }
+ lockWrite(g);
+ try { for (Node n : toRemove) g.removeNode(n); }
+ finally { unlockWrite(g); }
+ refreshPreviewOnEDT(ws);
+ JsonObject r = success("Giant component extracted");
+ r.addProperty("kept_nodes", giantSize);
+ r.addProperty("removed_nodes", toRemove.size());
+ r.addProperty("component_count", componentSizes.size());
+ return r;
+ } 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 ────────────────────────────────────────────────────
+
+ /** The layout executor, recreated if a previous shutdown() killed it. */
+ private synchronized ExecutorService layoutExecutor() {
+ if (layoutExecutor == null || layoutExecutor.isShutdown()) {
+ layoutExecutor = Executors.newSingleThreadExecutor();
+ }
+ return layoutExecutor;
+ }
+
+ 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;
+ }
+
+ // ─── Human selection journal ─────────────────────────────────────────
+
+ /**
+ * Install the passive NODE_LEFT_CLICK listener once. Safe to call often;
+ * no-ops until the visualization is available. The listener returns false
+ * (observe, never consume) so Gephi's own tools keep working.
+ */
+ public synchronized void ensureClickListener() {
+ if (clickListenerInstalled) return;
+ org.gephi.visualization.api.VisualizationController vc =
+ Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class);
+ if (vc == null) return;
+ vc.addListener(new org.gephi.visualization.api.VisualizationEventListener() {
+ @Override
+ public boolean handleEvent(org.gephi.visualization.api.VisualizationEvent event) {
+ try {
+ Object data = event.getData();
+ if (data instanceof Node[]) {
+ Node[] nodes = (Node[]) data;
+ if (nodes.length > 0) recordClick(nodes);
+ }
+ } catch (Throwable t) {
+ // Never disturb the viz event thread.
+ }
+ return false;
+ }
+
+ @Override
+ public org.gephi.visualization.api.VisualizationEvent.Type getType() {
+ return org.gephi.visualization.api.VisualizationEvent.Type.NODE_LEFT_CLICK;
+ }
+ });
+ clickListenerInstalled = true;
+ // Deliberately does NOT enable rectangle selection. Installing the listener is
+ // passive observation and is safe to do at startup, which is where it happens so
+ // that clicks made before an assistant ever connects are still recorded. Changing
+ // the mouse mode is not passive: it would alter the tool every user of this plugin
+ // sees on every launch, including those who never connect an assistant.
+ // getSelection() enables rectangle selection instead, because a caller asking what
+ // is selected is the point at which the user is actually driving the assistant.
+ }
+
+ /**
+ * Turn on rectangle (box-drag) selection once per session so the human can
+ * point at nodes for the agent to read, without first clicking the toolbar's
+ * selection tool. No-op if the view isn't started yet (retried on the next
+ * call) or if it is already on. Never overrides a mode the human later sets
+ * on their own — it fires at most once, and only while selection is still off.
+ */
+ void ensureRectangleSelection() {
+ if (rectangleAutoEnabled) return;
+ try {
+ org.gephi.visualization.api.VisualizationController vc = Lookup.getDefault()
+ .lookup(org.gephi.visualization.api.VisualizationController.class);
+ if (vc == null) return;
+ org.gephi.visualization.api.VisualizationModel model = vc.getModel();
+ if (model == null) return; // view not started; try again next call
+ if (!model.isRectangleSelection()) vc.setRectangleSelection();
+ rectangleAutoEnabled = true;
+ } catch (Throwable t) {
+ // Never disturb a health/selection call over a viz hiccup.
+ }
+ }
+
+ private void recordClick(Node[] nodes) {
+ JsonObject entry = new JsonObject();
+ entry.addProperty("time_ms", System.currentTimeMillis());
+ JsonArray arr = new JsonArray();
+ for (Node n : nodes) {
+ JsonObject jn = new JsonObject();
+ jn.addProperty("id", String.valueOf(n.getId()));
+ String label = n.getLabel();
+ if (label != null && !label.isEmpty() && !label.equals(String.valueOf(n.getId()))) {
+ jn.addProperty("label", label);
+ }
+ arr.add(jn);
+ }
+ entry.add("nodes", arr);
+ synchronized (clickJournal) {
+ clickJournal.addLast(entry);
+ while (clickJournal.size() > CLICK_JOURNAL_MAX) clickJournal.removeFirst();
+ }
+ }
+
+ private static final int SELECTION_MAX_NODES = 200;
+
+ private JsonObject nodeRef(Node n) {
+ JsonObject jn = new JsonObject();
+ jn.addProperty("id", String.valueOf(n.getId()));
+ String label = n.getLabel();
+ if (label != null && !label.isEmpty() && !label.equals(String.valueOf(n.getId()))) {
+ jn.addProperty("label", label);
+ }
+ return jn;
+ }
+
+ /**
+ * What the human has selected in the Gephi window. Two sources:
+ * selected_now — the engine's persistent selection (rectangle selection
+ * keeps it after the mouse moves away; the primary channel), read via
+ * reflection (VizController.getEngine() -> VizEngine.getGraphSelection()
+ * -> getSelectedNodes(), all public, reflection only to avoid a
+ * compile-time dependency on the engine module); clicks — the
+ * NODE_LEFT_CLICK journal (fires only in modes that populate the engine
+ * selection at click time). clear=true consumes the journal only; the
+ * live selection always reflects the canvas.
+ */
+ public JsonObject getSelection(boolean clear) {
+ ensureClickListener();
+ ensureRectangleSelection();
+ JsonObject r = success("Human selection");
+ JsonArray selected = new JsonArray();
+ int totalSelected = 0;
+ try {
+ org.gephi.visualization.api.VisualizationController vc = Lookup.getDefault()
+ .lookup(org.gephi.visualization.api.VisualizationController.class);
+ if (vc != null) {
+ // Report the canvas state so the agent can explain an empty selection
+ // (e.g. rectangle mode off) instead of silently returning nothing.
+ org.gephi.visualization.api.VisualizationModel model = vc.getModel();
+ java.util.Collection sel = null;
+ if (model != null) {
+ r.addProperty("selection_enabled", model.isSelectionEnabled());
+ r.addProperty("rectangle_selection", model.isRectangleSelection());
+ r.addProperty("zoom", model.getZoom());
+ // Public read path — no dependency on the internal viz engine.
+ sel = model.getSelectedNodes();
+ }
+ if (sel == null) sel = engineSelectionFallback(vc); // older builds
+ if (sel != null) {
+ for (Node n : sel) {
+ totalSelected++;
+ if (selected.size() < SELECTION_MAX_NODES) selected.add(nodeRef(n));
+ }
+ }
+ }
+ } catch (Throwable t) {
+ r.addProperty("selection_error", t.getClass().getSimpleName() + ": " + t.getMessage());
+ }
+ r.add("selected_now", selected);
+ r.addProperty("selected_count", totalSelected);
+ if (totalSelected > SELECTION_MAX_NODES) {
+ r.addProperty("selected_truncated", true);
+ }
+ JsonArray clicks = new JsonArray();
+ synchronized (clickJournal) {
+ for (JsonObject e : clickJournal) clicks.add(e.deepCopy());
+ if (clear) clickJournal.clear();
+ }
+ r.add("clicks", clicks);
+ r.addProperty("click_count", clicks.size());
+ r.addProperty("listener_active", clickListenerInstalled);
+ return r;
+ }
+
+ /**
+ * Legacy read path for Gephi builds whose VisualizationModel does not carry the
+ * selection: reflect into the render engine
+ * (VisualizationController.getEngine() -> VizEngine.getGraphSelection() ->
+ * getSelectedNodes()). Returns null when unavailable so the caller can fall back
+ * to an empty selection. Reflection-only to avoid a compile-time dependency on
+ * the engine module.
+ */
+ @SuppressWarnings("unchecked")
+ private java.util.Collection engineSelectionFallback(Object vc) {
+ try {
+ Object opt = vc.getClass().getMethod("getEngine").invoke(vc);
+ if (opt instanceof java.util.Optional && ((java.util.Optional>) opt).isPresent()) {
+ Object engine = ((java.util.Optional>) opt).get();
+ Object gsel = engine.getClass().getMethod("getGraphSelection").invoke(engine);
+ if (gsel != null) {
+ java.lang.reflect.Method m = gsel.getClass().getMethod("getSelectedNodes");
+ m.setAccessible(true);
+ Object coll = m.invoke(gsel);
+ if (coll instanceof java.util.Collection) {
+ return (java.util.Collection) coll;
+ }
+ }
+ }
+ } catch (Throwable t) {
+ // Unavailable on this build; caller reports an empty selection.
+ }
+ return null;
+ }
+
+ // ─── 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)");
+ }
+ Integer selectedCount = null;
+ if (select != null) {
+ int expected;
+ if (select.isEmpty()) {
+ vc.resetSelection();
+ expected = 0;
+ } 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]));
+ expected = nodes.size();
+ }
+ // selectNodes()/resetSelection() apply asynchronously against the render
+ // engine (queued, not synchronous with this call) — wait briefly for the
+ // change to actually land instead of blindly echoing the request size, so
+ // a caller (e.g. gephi_get_selection or a screenshot right after) sees it
+ // too. Also correct for IDs that didn't resolve to a real node.
+ selectedCount = waitForSelectionCount(vc, expected, 1000);
+ }
+ if (zoom != null) vc.setZoom(zoom.floatValue());
+ JsonObject r = success("View focused (" + m + ")");
+ r.addProperty("mode", m);
+ if (selectedCount != null) r.addProperty("selected", selectedCount);
+ return r;
+ } catch (Exception e) {
+ return error("Focus failed: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Set the mouse selection mode on the graph canvas. "rectangle" enables the
+ * box-drag selection the pointing feature (readSelection) reads, so a
+ * teaching session can turn it on up front instead of asking the human to
+ * click the toolbar icon. Uses the same VisualizationController focusView
+ * already drives.
+ */
+ public JsonObject setSelectionMode(String mode) {
+ org.gephi.visualization.api.VisualizationController vc =
+ Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class);
+ if (vc == null) return error("No visualization available (headless or view not started)");
+ String m = mode == null ? "rectangle" : mode.toLowerCase();
+ try {
+ switch (m) {
+ case "rectangle":
+ vc.setRectangleSelection();
+ break;
+ case "direct":
+ vc.setDirectMouseSelection();
+ break;
+ case "disable":
+ case "off":
+ vc.disableSelection();
+ break;
+ default:
+ return error("Unknown selection mode: " + mode + " (use rectangle|direct|disable)");
+ }
+ JsonObject r = success("Selection mode set to " + m);
+ r.addProperty("mode", m);
+ return r;
+ } catch (Exception e) {
+ return error("Set selection mode failed: " + e.getMessage());
+ }
+ }
+
+ /** List the perspectives (Overview / Data Laboratory / Preview) and the active one. */
+ public JsonObject getPerspective() {
+ org.gephi.perspective.api.PerspectiveController pc =
+ Lookup.getDefault().lookup(org.gephi.perspective.api.PerspectiveController.class);
+ if (pc == null) return error("No perspective controller (headless?)");
+ try {
+ org.gephi.perspective.spi.Perspective selected = pc.getSelectedPerspective();
+ JsonObject r = success("Perspectives listed");
+ r.addProperty("selected", selected == null ? null : selected.getName());
+ com.google.gson.JsonArray arr = new com.google.gson.JsonArray();
+ for (org.gephi.perspective.spi.Perspective p : pc.getPerspectives()) {
+ JsonObject o = new JsonObject();
+ o.addProperty("name", p.getName());
+ o.addProperty("display_name", p.getDisplayName());
+ o.addProperty("selected", p == selected);
+ arr.add(o);
+ }
+ r.add("perspectives", arr);
+ return r;
+ } catch (Exception e) {
+ return error("List perspectives failed: " + e.getMessage());
+ }
+ }
+
+ /** Switch the active perspective (tab) by name or display name (case-insensitive). */
+ public JsonObject switchPerspective(String name) {
+ org.gephi.perspective.api.PerspectiveController pc =
+ Lookup.getDefault().lookup(org.gephi.perspective.api.PerspectiveController.class);
+ if (pc == null) return error("No perspective controller (headless?)");
+ if (name == null) return error("Missing 'name'");
+ org.gephi.perspective.spi.Perspective match = null;
+ for (org.gephi.perspective.spi.Perspective p : pc.getPerspectives()) {
+ if (name.equalsIgnoreCase(p.getName()) || name.equalsIgnoreCase(p.getDisplayName())) {
+ match = p;
+ break;
+ }
+ }
+ if (match == null) return error("Perspective not found: " + name);
+ final org.gephi.perspective.spi.Perspective target = match;
+ // Switching the perspective mutates the NetBeans window system — do it on the EDT.
+ return runOnEDT(() -> {
+ pc.selectPerspective(target);
+ JsonObject r = success("Switched to perspective: " + target.getDisplayName());
+ r.addProperty("selected", target.getName());
+ return r;
+ });
+ }
+
+ // ─── Filters (Group C) ───────────────────────────────────────────
+
+ /**
+ * Every filter builder available, static and dynamic. Static builders
+ * (DegreeRange, KCore, GiantComponent, Ego, …) come straight from Lookup;
+ * per-column attribute builders (AttributeEqual/Range/NonNull on each
+ * column) come from CategoryBuilder.getBuilders(workspace) and only exist
+ * once a graph with columns is loaded.
+ */
+ private java.util.List allFilterBuilders(Workspace ws) {
+ java.util.List out = new java.util.ArrayList<>();
+ for (FilterBuilder b : Lookup.getDefault().lookupAll(FilterBuilder.class)) {
+ out.add(b);
+ }
+ for (CategoryBuilder cb : Lookup.getDefault().lookupAll(CategoryBuilder.class)) {
+ try {
+ FilterBuilder[] bs = cb.getBuilders(ws);
+ if (bs != null) java.util.Collections.addAll(out, bs);
+ } catch (Exception ignore) { /* some category builders need a specific state */ }
+ }
+ return out;
+ }
+
+ /** Coerce a JSON value to a filter property's type; handles Range from a [lo, hi] pair. */
+ static Object convertFilterProperty(Object val, Class> type) {
+ if (val == null) return null;
+ if (type == org.gephi.filters.api.Range.class) {
+ java.util.List> pair = null;
+ if (val instanceof java.util.List) pair = (java.util.List>) val;
+ else if (val instanceof com.google.gson.JsonArray) {
+ java.util.List l = new java.util.ArrayList<>();
+ for (com.google.gson.JsonElement e : (com.google.gson.JsonArray) val) l.add(e.getAsDouble());
+ pair = l;
+ }
+ if (pair == null || pair.size() != 2) return null;
+ double loD = pair.get(0) instanceof Number ? ((Number) pair.get(0)).doubleValue() : Double.parseDouble(pair.get(0).toString());
+ double hiD = pair.get(1) instanceof Number ? ((Number) pair.get(1)).doubleValue() : Double.parseDouble(pair.get(1).toString());
+ // Range requires both bounds to be the SAME Number class. Use Integer when
+ // both are whole (degree/count filters), Double otherwise (continuous columns).
+ boolean whole = loD == Math.floor(loD) && hiD == Math.floor(hiD)
+ && !Double.isInfinite(loD) && !Double.isInfinite(hiD);
+ if (whole) return new org.gephi.filters.api.Range((int) loD, (int) hiD);
+ return new org.gephi.filters.api.Range(loD, hiD);
+ }
+ return convertLayoutProperty(val, type);
+ }
+
+ public JsonObject listFilters() {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No workspace open");
+ JsonArray arr = new JsonArray();
+ for (FilterBuilder b : allFilterBuilders(ws)) {
+ JsonObject o = new JsonObject();
+ try { o.addProperty("name", b.getName()); } catch (Exception ignore) {}
+ try { o.addProperty("category", b.getCategory() == null ? null : b.getCategory().getName()); } catch (Exception ignore) {}
+ try { o.addProperty("description", b.getDescription()); } catch (Exception ignore) {}
+ // Introspect the filter's settable properties so callers know what params to pass.
+ try {
+ Filter f = b.getFilter(ws);
+ if (f != null && f.getProperties() != null) {
+ JsonArray props = new JsonArray();
+ for (FilterProperty p : f.getProperties()) {
+ JsonObject po = new JsonObject();
+ po.addProperty("name", p.getName());
+ po.addProperty("type", p.getValueType() == null ? null : p.getValueType().getSimpleName());
+ props.add(po);
+ }
+ o.add("properties", props);
+ }
+ } catch (Exception ignore) { /* introspection best-effort */ }
+ arr.add(o);
+ }
+ JsonObject r = success("Filters listed");
+ r.add("filters", arr);
+ return r;
+ }
+
+ public JsonObject applyFilter(String name, Map params, String action, String column) {
+ FilterController fc = Lookup.getDefault().lookup(FilterController.class);
+ if (fc == null) return error("No filter controller available");
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No workspace open");
+ GraphModel gm = currentGraphModel();
+ if (gm == null) return error("No workspace open");
+ if (name == null) return error("Missing 'name'");
+
+ FilterBuilder builder = null;
+ for (FilterBuilder b : allFilterBuilders(ws)) {
+ try { if (name.equalsIgnoreCase(b.getName())) { builder = b; break; } } catch (Exception ignore) {}
+ }
+ if (builder == null) return error("Filter not found: " + name + " (call /filter/list to see available filters)");
+
+ Filter filter = builder.getFilter(ws);
+ if (filter == null) return error("Filter builder produced no filter: " + name);
+
+ // Set each named property; report the valid names if a param doesn't match.
+ FilterProperty[] props = filter.getProperties();
+ if (params != null && !params.isEmpty()) {
+ java.util.List propNames = new java.util.ArrayList<>();
+ if (props != null) for (FilterProperty p : props) propNames.add(p.getName());
+ for (Map.Entry e : params.entrySet()) {
+ FilterProperty match = null;
+ if (props != null) {
+ for (FilterProperty p : props) {
+ if (e.getKey().equalsIgnoreCase(p.getName())) { match = p; break; }
+ }
+ }
+ if (match == null) {
+ return error("Unknown filter property '" + e.getKey() + "' for " + name
+ + " — valid properties: " + propNames);
+ }
+ Object converted = convertFilterProperty(e.getValue(), match.getValueType());
+ if (converted == null) {
+ return error("Could not coerce '" + e.getKey() + "' to " + match.getValueType().getSimpleName()
+ + " (Range wants a [lo, hi] pair)");
+ }
+ try { match.setValue(converted); }
+ catch (Exception ex) { return error("Failed to set '" + e.getKey() + "': " + ex.getMessage()); }
+ }
+ }
+
+ int nodesBefore = gm.getGraphVisible().getNodeCount();
+ int edgesBefore = gm.getGraphVisible().getEdgeCount();
+
+ // Validate the action BEFORE touching the filter model. Adding the query first
+ // meant an unknown action returned an error having already changed the visible
+ // graph, so a caller that trusted the error saw a silently filtered graph.
+ String act = action == null ? "select" : action.toLowerCase();
+ switch (act) {
+ case "select":
+ case "visible":
+ case "new_workspace":
+ break;
+ case "column":
+ if (column == null) return error("action=column requires a 'column' name");
+ break;
+ default:
+ return error("Unknown action: " + action + " (use select|new_workspace|column)");
+ }
+
+ Query query = fc.createQuery(filter);
+ fc.add(query);
+
+ // All three FilterController operations below end in Gephi's own BLOCKING
+ // writeLock() (filterVisible via setVisibleView; the two exports process the
+ // query through the same path). Hold our deadlock-safe lock first so those
+ // calls re-enter instead of queuing behind the renderer — exactly the
+ // mitigation resetFilters uses, and these run on a NanoHTTPD thread too.
+ Graph lockGraph = gm.getGraph();
+ JsonObject r;
+ switch (act) {
+ case "select":
+ case "visible":
+ lockWrite(lockGraph);
+ try {
+ fc.filterVisible(query);
+ } finally { unlockWrite(lockGraph); }
+ r = success("Filter applied to the visible graph");
+ r.addProperty("nodes_before", nodesBefore);
+ r.addProperty("edges_before", edgesBefore);
+ // filterVisible ends in setVisibleView, which does not finish swapping the
+ // view before it returns. Reading the counts straight away reported the
+ // pre-filter numbers, telling the caller the filter removed nothing when it
+ // had removed half the graph. Wait briefly for the view to settle, and say
+ // so rather than publishing a number that has not stopped moving.
+ boolean settled = awaitVisibleViewSettled(gm, nodesBefore);
+ r.addProperty("nodes_after", gm.getGraphVisible().getNodeCount());
+ r.addProperty("edges_after", gm.getGraphVisible().getEdgeCount());
+ if (!settled) r.addProperty("counts_settled", false);
+ break;
+ case "new_workspace":
+ // Materializes the filtered subgraph into a fresh workspace — the
+ // memory-safe way to filter repeatedly (hidden GraphView elements
+ // otherwise stay resident).
+ lockWrite(lockGraph);
+ try {
+ fc.exportToNewWorkspace(query);
+ } finally { unlockWrite(lockGraph); }
+ r = success("Filtered subgraph exported to a new workspace");
+ break;
+ case "column":
+ if (column == null) return error("action=column requires a 'column' name");
+ lockWrite(lockGraph);
+ try {
+ fc.exportToColumn(column, query);
+ } finally { unlockWrite(lockGraph); }
+ r = success("Filter membership written to boolean column: " + column);
+ r.addProperty("column", column);
+ break;
+ default:
+ return error("Unknown action: " + action + " (use select|new_workspace|column)");
+ }
+ try { r.addProperty("filter", builder.getName()); } catch (Exception ignore) {}
+ return r;
+ }
+
+ /**
+ * Waits briefly for the visible view to stop changing after a filter is applied.
+ * Returns true once two consecutive reads agree (and, when the filter actually
+ * removed something, once the count has moved off its pre-filter value); false if
+ * it was still moving when the budget ran out, so the caller can say the number is
+ * provisional instead of presenting a moving value as final.
+ */
+ private static boolean awaitVisibleViewSettled(GraphModel gm, int before) {
+ final long budgetMs = 1500;
+ final long deadline = System.currentTimeMillis() + budgetMs;
+ int last = -1;
+ boolean moved = false;
+ while (System.currentTimeMillis() < deadline) {
+ int now = gm.getGraphVisible().getNodeCount();
+ if (now != before) moved = true;
+ if (now == last && (moved || now != before)) return true;
+ last = now;
+ try {
+ Thread.sleep(25);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ // A filter that legitimately keeps every node never moves off `before`; treat a
+ // stable reading as settled rather than reporting it as provisional forever.
+ return gm.getGraphVisible().getNodeCount() == last;
+ }
+
+ // ─── Data Laboratory (Group D) ───────────────────────────────────
+
+ private static Table tableFor(GraphModel gm, String target) {
+ return "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable();
+ }
+
+ private static org.gephi.graph.api.Element[] elementsFor(GraphModel gm, String target) {
+ Graph g = gm.getGraph();
+ return "edge".equalsIgnoreCase(target) ? g.getEdges().toArray() : g.getNodes().toArray();
+ }
+
+ /**
+ * Value -> count over one column. Pure GraphModel logic (no datalab
+ * controller / running Gephi needed), so it is unit-testable against an
+ * in-memory model.
+ */
+ static JsonObject columnValueFrequenciesCore(GraphModel gm, String target, String columnId) {
+ Table table = tableFor(gm, target);
+ Column col = table.getColumn(columnId);
+ if (col == null) return error("Column not found: " + columnId);
+ java.util.LinkedHashMap freq = new java.util.LinkedHashMap<>();
+ int total = 0;
+ for (org.gephi.graph.api.Element el : elementsFor(gm, target)) {
+ Object v = el.getAttribute(col);
+ String key = v == null ? "" : v.toString();
+ freq.merge(key, 1, Integer::sum);
+ total++;
+ }
+ JsonObject r = success("Column value frequencies computed");
+ r.addProperty("column", columnId);
+ r.addProperty("target", "edge".equalsIgnoreCase(target) ? "edge" : "node");
+ r.addProperty("total", total);
+ r.addProperty("distinct_values", freq.size());
+ JsonObject f = new JsonObject();
+ for (Map.Entry e : freq.entrySet()) f.addProperty(e.getKey(), e.getValue());
+ r.add("frequencies", f);
+ return r;
+ }
+
+ /**
+ * Groups of elements that share a value in one column (size >= 2). Pure
+ * GraphModel logic, unit-testable. caseSensitive controls string matching.
+ */
+ static JsonObject detectDuplicatesCore(GraphModel gm, String target, String columnId, boolean caseSensitive) {
+ Table table = tableFor(gm, target);
+ Column col = table.getColumn(columnId);
+ if (col == null) return error("Column not found: " + columnId);
+ java.util.LinkedHashMap> groups = new java.util.LinkedHashMap<>();
+ for (org.gephi.graph.api.Element el : elementsFor(gm, target)) {
+ Object v = el.getAttribute(col);
+ if (v == null) continue;
+ String key = v.toString();
+ if (!caseSensitive) key = key.toLowerCase();
+ groups.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(String.valueOf(el.getId()));
+ }
+ JsonArray dupes = new JsonArray();
+ int groupCount = 0;
+ for (java.util.List ids : groups.values()) {
+ if (ids.size() >= 2) {
+ groupCount++;
+ JsonArray a = new JsonArray();
+ for (String id : ids) a.add(id);
+ dupes.add(a);
+ }
+ }
+ JsonObject r = success("Duplicate detection complete");
+ r.addProperty("column", columnId);
+ r.addProperty("group_count", groupCount);
+ r.add("duplicate_groups", dupes);
+ return r;
+ }
+
+ public JsonObject columnValueFrequencies(String target, String columnId) {
+ GraphModel gm = currentGraphModel();
+ if (gm == null) return error("No workspace open");
+ if (columnId == null) return error("Missing 'column'");
+ return columnValueFrequenciesCore(gm, target, columnId);
+ }
+
+ public JsonObject detectDuplicates(String target, String columnId, boolean caseSensitive) {
+ GraphModel gm = currentGraphModel();
+ if (gm == null) return error("No workspace open");
+ if (columnId == null) return error("Missing 'column'");
+ return detectDuplicatesCore(gm, target, columnId, caseSensitive);
+ }
+
+ /** Merge several nodes into one, reassigning edges; deletes the merged-away nodes. */
+ public JsonObject mergeNodes(java.util.List ids, String intoId) {
+ GraphModel gm = currentGraphModel();
+ if (gm == null) return error("No workspace open");
+ if (ids == null || ids.isEmpty()) return error("Missing 'ids'");
+ org.gephi.datalab.api.GraphElementsController gec =
+ Lookup.getDefault().lookup(org.gephi.datalab.api.GraphElementsController.class);
+ if (gec == null) return error("No datalab controller available");
+ Graph g = gm.getGraph();
+ java.util.List nodes = new java.util.ArrayList<>();
+ for (String id : ids) {
+ Node n = g.getNode(id);
+ if (n == null) return error("Node not found: " + id);
+ nodes.add(n);
+ }
+ Node into = intoId != null ? g.getNode(intoId) : nodes.get(0);
+ if (into == null) return error("Merge target node not found: " + intoId);
+ try {
+ // Empty column/strategy arrays: reassign edges and keep the `into` node's
+ // own attribute values (no per-column value merge). Passing null throws
+ // an NPE inside the controller (it reads columns.length).
+ Node result = gec.mergeNodes(g, nodes.toArray(new Node[0]), into,
+ new Column[0], new org.gephi.datalab.spi.rows.merge.AttributeRowsMergeStrategy[0], true);
+ JsonObject r = success("Merged " + nodes.size() + " nodes");
+ r.addProperty("into", result != null ? String.valueOf(result.getId()) : String.valueOf(into.getId()));
+ r.addProperty("merged_count", nodes.size());
+ return r;
+ } catch (Exception e) {
+ return error("Merge failed: " + e.getMessage());
+ }
+ }
+
+ // ─── Edge appearance + generic export (Group E) ──────────────────
+
+ /**
+ * Color edges by an edge-column partition (relationship type, time period,
+ * weight tier, …) — the edge twin of colorByPartition. Mirrors it exactly:
+ * per-value palette (supplied or auto), then edge.setColor per row.
+ */
+ public JsonObject colorEdgesByPartition(String columnName, Map colorMap) {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ try {
+ GraphModel gm = currentGraphModel();
+ Graph graph = gm.getGraph();
+ Column col = gm.getEdgeTable().getColumn(columnName);
+ if (col == null) return error("Edge column not found: " + columnName);
+
+ java.util.Map palette = new java.util.LinkedHashMap<>();
+ if (colorMap != null && !colorMap.isEmpty()) {
+ for (Map.Entry e : colorMap.entrySet()) {
+ int[] c = e.getValue();
+ palette.put(e.getKey(), new Color(c[0], c[1], c[2]));
+ }
+ } else {
+ java.util.Set values = new java.util.LinkedHashSet<>();
+ for (Edge ed : graph.getEdges().toArray()) {
+ Object v = ed.getAttribute(col);
+ if (v != null) values.add(v.toString());
+ }
+ Color[] defaultPalette = {
+ new Color(31, 119, 180), new Color(255, 127, 14), new Color(44, 160, 44),
+ new Color(214, 39, 40), new Color(148, 103, 189), new Color(140, 86, 75),
+ new Color(227, 119, 194), new Color(127, 127, 127), new Color(188, 189, 34),
+ new Color(23, 190, 207), new Color(174, 199, 232), new Color(255, 187, 120)
+ };
+ int idx = 0;
+ for (String v : values) { palette.put(v, defaultPalette[idx % defaultPalette.length]); idx++; }
+ }
+
+ int colored = 0;
+ lockWrite(graph);
+ try {
+ for (Edge ed : graph.getEdges().toArray()) {
+ Object v = ed.getAttribute(col);
+ if (v != null) {
+ Color c = palette.get(v.toString());
+ if (c != null) { ed.setColor(c); colored++; }
+ }
+ }
+ } finally { unlockWrite(graph); }
+ JsonObject r = success("Colored " + colored + " edges by " + columnName);
+ r.addProperty("partitions", palette.size());
+ return r;
+ } catch (Exception e) { return error("Failed: " + e.getMessage()); }
+ }
+
+ /**
+ * Export the graph in any format the ExportController knows by name — vna,
+ * pajek, dl, spreadsheet, gdf, gml, json, gexf, graphml, csv — for
+ * interchange with UCINET and other SNA tools, or a spreadsheet for
+ * non-technical readers. The wrapped-today formats (gexf/graphml/csv) keep
+ * their dedicated tools; this is the passthrough for the rest.
+ */
+ public JsonObject exportByFormat(String filePath, String format) {
+ return exportByFormat(filePath, format, true);
+ }
+
+ /** @param visible see exportGexf — same contract, response self-declares the view. */
+ public JsonObject exportByFormat(String filePath, String format, boolean visible) {
+ return runOnEDT(() -> {
+ Workspace ws = currentWorkspace();
+ if (ws == null) return error("No project open");
+ if (filePath == null || format == null) return error("Missing 'file' or 'format'");
+ try {
+ ExportController ec = Lookup.getDefault().lookup(ExportController.class);
+ Exporter exporter = ec.getExporter(format);
+ if (exporter == null) return error("No exporter for format: " + format
+ + " (try vna, pajek, dl, spreadsheet, gdf, gml, json, gexf, graphml, csv)");
+ if (exporter instanceof GraphExporter) {
+ ((GraphExporter) exporter).setExportVisible(visible);
+ ((GraphExporter) exporter).setWorkspace(ws);
+ }
+ ec.exportFile(new File(filePath), exporter);
+ JsonObject r = success("Exported to " + filePath);
+ r.addProperty("format", format);
+ addViewInfo(r, currentGraphModel(), visible);
+ return r;
+ } catch (Exception e) { return error("Export failed: " + e.getMessage()); }
+ });
+ }
+
+ // ─── Timeline / dynamic (Group G) ────────────────────────────────
+
+ /**
+ * Report the graph's dynamic/timeline state. Doubles as the spike for the
+ * reported "Timeline doesn't recognize dynamic attributes after a
+ * programmatic import" bug: if graph_is_dynamic is true but
+ * dynamic_columns is empty, the bug reproduces on this Gephi.
+ */
+ public JsonObject getTimeline() {
+ GraphModel gm = currentGraphModel();
+ if (gm == null) return error("No workspace open");
+ JsonObject r = success("Timeline state");
+ try {
+ r.addProperty("graph_is_dynamic", gm.isDynamic());
+ org.gephi.graph.api.Interval b = gm.getTimeBounds();
+ if (b != null) {
+ r.addProperty("time_min", b.getLow());
+ r.addProperty("time_max", b.getHigh());
+ }
+ r.addProperty("time_format", String.valueOf(gm.getTimeFormat()));
+ } catch (Exception e) { r.addProperty("bounds_error", e.getMessage()); }
+ org.gephi.timeline.api.TimelineController tc =
+ Lookup.getDefault().lookup(org.gephi.timeline.api.TimelineController.class);
+ if (tc != null) {
+ try {
+ JsonArray cols = new JsonArray();
+ String[] dc = tc.getDynamicGraphColumns();
+ if (dc != null) for (String c : dc) cols.add(c);
+ r.add("dynamic_columns", cols);
+ org.gephi.timeline.api.TimelineModel tm = tc.getModel();
+ if (tm != null) {
+ r.addProperty("timeline_enabled", tm.isEnabled());
+ r.addProperty("has_valid_bounds", tm.hasValidBounds());
+ if (tm.hasValidBounds()) {
+ r.addProperty("interval_start", tm.getIntervalStart());
+ r.addProperty("interval_end", tm.getIntervalEnd());
+ }
+ }
+ } catch (Exception e) { r.addProperty("timeline_error", e.getMessage()); }
+ } else {
+ r.addProperty("timeline_controller", "unavailable");
+ }
+ return r;
+ }
+
+ // REMOVED: setTimeWindow. Driving Gephi's timeline from outside wedges the
+ // EDT two different ways — a time-derived setVisibleView deadlocks the
+ // renderer, and even setInterval/setEnabled saturates the EDT after one call.
+ // Because Gephi's own shutdown runs on the EDT, a wedged timeline op makes
+ // the app impossible to quit normally (Force Quit only). getTimeline
+ // (read-only, above) is safe and kept; any future write path must go through
+ // the viz-engine render-pause and off the EDT before it can be revived.
+
+ /** Create a boolean column flagging rows whose column value matches a regex. */
+ public JsonObject createRegexColumn(String target, String columnId, String newColumnTitle, String regex) {
+ GraphModel gm = currentGraphModel();
+ if (gm == null) return error("No workspace open");
+ if (columnId == null || regex == null || newColumnTitle == null)
+ return error("Missing 'column', 'regex', or 'new_column'");
+ org.gephi.datalab.api.AttributeColumnsController acc =
+ Lookup.getDefault().lookup(org.gephi.datalab.api.AttributeColumnsController.class);
+ if (acc == null) return error("No datalab controller available");
+ Table table = tableFor(gm, target);
+ Column col = table.getColumn(columnId);
+ if (col == null) return error("Column not found: " + columnId);
+ try {
+ java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex);
+ Column created = acc.createBooleanMatchesColumn(table, col, newColumnTitle, pattern);
+ JsonObject r = success("Created boolean match column: " + newColumnTitle);
+ r.addProperty("column", created != null ? created.getId() : newColumnTitle);
+ return r;
+ } catch (java.util.regex.PatternSyntaxException e) {
+ return error("Invalid regex: " + e.getMessage());
+ } catch (Exception e) {
+ return error("Create match column failed: " + e.getMessage());
+ }
+ }
+
+}
diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java
new file mode 100644
index 000000000..b7184cec9
--- /dev/null
+++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/RenderPause.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/BindFailureNotifier.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/BindFailureNotifier.java
new file mode 100644
index 000000000..95146f5f6
--- /dev/null
+++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/BindFailureNotifier.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.ui;
+
+import java.awt.GraphicsEnvironment;
+import org.openide.DialogDisplayer;
+import org.openide.NotifyDescriptor;
+import org.openide.util.NbBundle;
+
+/**
+ * Shows server startup failures to the user in a dialog, so a port clash is
+ * never silent. Kept in the ui package so the module lifecycle classes stay
+ * free of user interface code.
+ */
+public final class BindFailureNotifier {
+
+ private BindFailureNotifier() {
+ }
+
+ /**
+ * Shows the message in an error dialog. Safe to call from any thread;
+ * DialogDisplayer.notifyLater queues the dialog for the event dispatch
+ * thread.
+ */
+ public static void notifyStartupFailure(final String message) {
+ if (GraphicsEnvironment.isHeadless()) {
+ return;
+ }
+ NotifyDescriptor descriptor =
+ new NotifyDescriptor.Message(message, NotifyDescriptor.ERROR_MESSAGE);
+ descriptor.setTitle(NbBundle.getMessage(BindFailureNotifier.class,
+ "BindFailureNotifier.title"));
+ DialogDisplayer.getDefault().notifyLater(descriptor);
+ }
+}
diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlAction.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlAction.java
new file mode 100644
index 000000000..07e217a65
--- /dev/null
+++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlAction.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.ui;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import org.openide.awt.ActionID;
+import org.openide.awt.ActionReference;
+import org.openide.awt.ActionRegistration;
+
+/**
+ * Tools menu entry that opens the Gephi AI server control dialog. The
+ * registration annotations generate the layer entries at compile time.
+ */
+@ActionID(category = "Tools", id = "org.gephi.plugins.mcp.ui.ServerControlAction")
+@ActionRegistration(displayName = "#CTL_ServerControlAction")
+@ActionReference(path = "Menu/Tools", position = 1550)
+public final class ServerControlAction implements ActionListener {
+
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ ServerControlPanel.showDialog();
+ }
+}
diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlPanel.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlPanel.java
new file mode 100644
index 000000000..76c87d055
--- /dev/null
+++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlPanel.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.ui;
+
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.event.ActionListener;
+import javax.swing.JButton;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.SwingUtilities;
+import org.gephi.plugins.mcp.Installer;
+import org.openide.DialogDescriptor;
+import org.openide.DialogDisplayer;
+import org.openide.util.NbBundle;
+import org.openide.util.RequestProcessor;
+
+/**
+ * Control panel for the Gephi AI server: shows whether the server is running
+ * and at which URL, lets the user start and stop it, and lets the user change
+ * the port. The port is persisted through Installer.setPreferredPort and takes
+ * effect on the next start. Start and stop run off the event dispatch thread.
+ */
+public final class ServerControlPanel extends JPanel {
+
+ private static final RequestProcessor RP = new RequestProcessor("GephiAI-ServerControl", 1);
+
+ static final int MIN_PORT = 1024;
+ static final int MAX_PORT = 65535;
+
+ private final JLabel statusLabel = new JLabel();
+ private final JTextField portField = new JTextField(6);
+ private final JButton startButton = new JButton(msg("ServerControlPanel.start"));
+ private final JButton stopButton = new JButton(msg("ServerControlPanel.stop"));
+ private final JLabel messageLabel = new JLabel(" ");
+
+ ServerControlPanel() {
+ super(new GridBagLayout());
+ portField.setText(Integer.toString(Installer.getPreferredPort()));
+ startButton.addActionListener(e -> onStart());
+ stopButton.addActionListener(e -> onStop());
+ buildLayout();
+ refresh();
+ }
+
+ /** Opens the control dialog. Must be called on the event dispatch thread. */
+ public static void showDialog() {
+ ServerControlPanel panel = new ServerControlPanel();
+ String close = msg("ServerControlPanel.close");
+ // The four argument constructor is used deliberately: the longer ones
+ // carry HelpCtx in their signatures, which lives in org-openide-util-ui,
+ // a module this plugin does not depend on.
+ DialogDescriptor descriptor = new DialogDescriptor(
+ panel, msg("ServerControlPanel.title"), true, (ActionListener) null);
+ descriptor.setOptions(new Object[]{close});
+ DialogDisplayer.getDefault().notify(descriptor);
+ }
+
+ private void buildLayout() {
+ GridBagConstraints c = new GridBagConstraints();
+ c.anchor = GridBagConstraints.WEST;
+ c.insets = new Insets(4, 4, 4, 4);
+
+ c.gridx = 0;
+ c.gridy = 0;
+ c.gridwidth = 4;
+ add(statusLabel, c);
+
+ c.gridy = 1;
+ c.gridwidth = 1;
+ add(new JLabel(msg("ServerControlPanel.portLabel")), c);
+ c.gridx = 1;
+ add(portField, c);
+ c.gridx = 2;
+ add(startButton, c);
+ c.gridx = 3;
+ add(stopButton, c);
+
+ c.gridx = 0;
+ c.gridy = 2;
+ c.gridwidth = 4;
+ add(new JLabel(msg("ServerControlPanel.portHint")), c);
+
+ c.gridy = 3;
+ add(messageLabel, c);
+ }
+
+ /**
+ * Parses a port field value. Returns the port when it is a usable number
+ * between MIN_PORT and MAX_PORT, and -1 otherwise. Static and free of
+ * Swing so it can be unit tested.
+ */
+ static int parsePort(String text) {
+ if (text == null) {
+ return -1;
+ }
+ try {
+ int port = Integer.parseInt(text.trim());
+ return (port >= MIN_PORT && port <= MAX_PORT) ? port : -1;
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+
+ private void onStart() {
+ int port = parsePort(portField.getText());
+ if (port < 0) {
+ messageLabel.setText(msg("ServerControlPanel.portInvalid"));
+ return;
+ }
+ Installer.setPreferredPort(port);
+ setBusy(true);
+ RP.post(() -> {
+ final String error = Installer.requestStart();
+ SwingUtilities.invokeLater(() -> {
+ messageLabel.setText(error == null ? " " : error);
+ setBusy(false);
+ refresh();
+ });
+ });
+ }
+
+ private void onStop() {
+ setBusy(true);
+ RP.post(() -> {
+ Installer.requestStop();
+ SwingUtilities.invokeLater(() -> {
+ messageLabel.setText(" ");
+ setBusy(false);
+ refresh();
+ });
+ });
+ }
+
+ private void setBusy(boolean busy) {
+ startButton.setEnabled(!busy);
+ stopButton.setEnabled(!busy);
+ portField.setEnabled(!busy);
+ }
+
+ private void refresh() {
+ if (Installer.isServerRunning()) {
+ String url = "http://127.0.0.1:" + Installer.getRunningPort();
+ statusLabel.setText(NbBundle.getMessage(ServerControlPanel.class,
+ "ServerControlPanel.status.running", url));
+ startButton.setEnabled(false);
+ stopButton.setEnabled(true);
+ } else {
+ statusLabel.setText(msg("ServerControlPanel.status.stopped"));
+ startButton.setEnabled(true);
+ stopButton.setEnabled(false);
+ }
+ }
+
+ private static String msg(String key) {
+ return NbBundle.getMessage(ServerControlPanel.class, key);
+ }
+}
diff --git a/modules/GephiAI/src/main/nbm/manifest.mf b/modules/GephiAI/src/main/nbm/manifest.mf
new file mode 100644
index 000000000..5b5387fd1
--- /dev/null
+++ b/modules/GephiAI/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/GephiAI/src/main/resources/org/gephi/plugins/mcp/Bundle.properties b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/Bundle.properties
new file mode 100644
index 000000000..36a4aefbf
--- /dev/null
+++ b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/Bundle.properties
@@ -0,0 +1,6 @@
+OpenIDE-Module-Name=Gephi AI
+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=Lets AI assistants such as Claude work in Gephi with you: create projects, build and edit graphs, run layouts, compute statistics, and export results, all from a conversation. The plugin runs a local HTTP API that a Model Context Protocol server connects to. The API listens on 127.0.0.1 only, on port 8080 by default, and can be started, stopped, and moved to another port from Tools, Gephi AI Server.\n\nDeveloped by Matt Artz (https://www.mattartz.me | ORCID: https://orcid.org/0000-0002-3822-1429)\nSource: https://github.com/MattArtzAnthro/gephi-ai
+Installer.error.bindFailed=The Gephi AI server could not start on port {0}: {1}. Another application may already be using that port. Choose a different port from Tools, Gephi AI Server, then start the server again.
+Installer.error.moduleNotReady=The Gephi AI module has not finished loading yet. Try again in a moment.
diff --git a/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/ui/Bundle.properties b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/ui/Bundle.properties
new file mode 100644
index 000000000..ec00a65fb
--- /dev/null
+++ b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/ui/Bundle.properties
@@ -0,0 +1,11 @@
+CTL_ServerControlAction=Gephi AI Server...
+BindFailureNotifier.title=Gephi AI
+ServerControlPanel.title=Gephi AI Server
+ServerControlPanel.status.running=Server running at {0}
+ServerControlPanel.status.stopped=Server stopped
+ServerControlPanel.portLabel=Port:
+ServerControlPanel.start=Start
+ServerControlPanel.stop=Stop
+ServerControlPanel.close=Close
+ServerControlPanel.portInvalid=Enter a port number between 1024 and 65535.
+ServerControlPanel.portHint=Port changes take effect the next time the server starts.
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/InstallerLifecycleTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/InstallerLifecycleTest.java
new file mode 100644
index 000000000..d6d9cf271
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/InstallerLifecycleTest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Guards the startup and shutdown race: restored() delays the server start, and
+ * a module disable that lands inside that delay must cancel the start instead
+ * of leaving an unreachable server running.
+ */
+class InstallerLifecycleTest {
+
+ @Test
+ void closeDuringStartupDelayCancelsTheStart() {
+ Installer installer = new Installer();
+ // The module is disabled while the delayed start is still pending.
+ installer.close();
+ // The delayed start arrives afterwards; it must not construct or bind a server.
+ assertNull(installer.startNow());
+ assertFalse(installer.isRunningNow());
+ }
+
+ @Test
+ void closingDuringStartupDelayCancelsTheStart() {
+ Installer installer = new Installer();
+ installer.closing();
+ assertNull(installer.startNow());
+ assertFalse(installer.isRunningNow());
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiSmokeTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiSmokeTest.java
new file mode 100644
index 000000000..2cca25a3b
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiSmokeTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.api;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Boots the real server on an ephemeral port and exercises it over HTTP, so the
+ * seam that ships is the seam that is tested. Covers the /health liveness probe
+ * and the DELETE /workspace/delete contract, whose index is a query parameter
+ * (request bodies are parsed for POST and PUT only).
+ */
+class ApiSmokeTest {
+
+ @Test
+ void healthAnswersAndWorkspaceDeleteRequiresTheIndexQueryParameter() throws Exception {
+ GephiAPIServer server = new GephiAPIServer(0);
+ server.startServer();
+ try {
+ int port = server.getListeningPort();
+
+ HttpURLConnection health = open(port, "/health", "GET");
+ assertEquals(200, health.getResponseCode());
+ String healthBody = read(health.getInputStream());
+ assertTrue(healthBody.contains("\"success\""), healthBody);
+ assertTrue(healthBody.contains("running"), healthBody);
+
+ HttpURLConnection delete = open(port, "/workspace/delete", "DELETE");
+ assertEquals(400, delete.getResponseCode());
+ String deleteBody = read(delete.getErrorStream());
+ assertTrue(deleteBody.contains("query parameter"), deleteBody);
+
+ HttpURLConnection deleteWithParam = open(port, "/workspace/delete?index=abc", "DELETE");
+ assertEquals(400, deleteWithParam.getResponseCode());
+ String badIndexBody = read(deleteWithParam.getErrorStream());
+ assertTrue(badIndexBody.contains("query parameter"), badIndexBody);
+ } finally {
+ // NanoHTTPD's own stop(); avoids shutting down the shared service
+ // singleton that other tests in the suite may still use.
+ server.stop();
+ }
+ }
+
+ private static HttpURLConnection open(int port, String path, String method) throws Exception {
+ URL url = new URL("http://127.0.0.1:" + port + path);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod(method);
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+ return conn;
+ }
+
+ private static String read(InputStream in) throws Exception {
+ if (in == null) {
+ return "";
+ }
+ try (InputStream is = in) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = is.read(buf)) > 0) {
+ out.write(buf, 0, n);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/BrowserOriginTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/BrowserOriginTest.java
new file mode 100644
index 000000000..9e124f204
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/BrowserOriginTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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;
+
+/**
+ * Guards against a web page driving the API.
+ *
+ * The Host-header check alone is not enough. A page the user is merely visiting can call
+ * {@code fetch("http://127.0.0.1:8080/graph/clear", {method:"POST", mode:"no-cors"})}: the
+ * browser sends {@code Host: 127.0.0.1:8080}, which the loopback check accepts, and a
+ * {@code text/plain} body is CORS-safelisted so no preflight is issued. CORS stops the page
+ * reading the reply, but the side effect has already happened, which is all an attacker needs
+ * to clear a workspace or write a file through an export endpoint.
+ *
+ *
Browsers attach {@code Origin} to such a request and {@code Sec-Fetch-Site} to every
+ * request, and page JavaScript cannot forge or suppress either. Non-browser clients (the MCP
+ * server, curl) send neither, so rejecting on them costs nothing.
+ */
+class BrowserOriginTest {
+
+ @Test
+ void nonBrowserClientsAreAccepted() {
+ // No Origin, no Sec-Fetch-Site: the MCP server, curl, any local process.
+ assertTrue(GephiAPIServer.isNonBrowserRequest(null, null));
+ assertTrue(GephiAPIServer.isNonBrowserRequest("", ""));
+ }
+
+ @Test
+ void requestsCarryingAnOriginAreRejected() {
+ assertFalse(GephiAPIServer.isNonBrowserRequest("https://evil.example", null));
+ assertFalse(GephiAPIServer.isNonBrowserRequest("http://localhost:3000", null));
+ assertFalse(GephiAPIServer.isNonBrowserRequest("null", null));
+ }
+
+ @Test
+ void crossSiteAndSameOriginBrowserFetchesAreRejected() {
+ assertFalse(GephiAPIServer.isNonBrowserRequest(null, "cross-site"));
+ assertFalse(GephiAPIServer.isNonBrowserRequest(null, "same-site"));
+ assertFalse(GephiAPIServer.isNonBrowserRequest(null, "same-origin"));
+ }
+
+ @Test
+ void userTypedNavigationIsStillRejectedWhenItCarriesAFetchMetadataHeader() {
+ // Sec-Fetch-Site: none means the user typed the URL or used a bookmark. That is a
+ // browser, and the API is not a browsing surface, so it is refused like any other.
+ assertFalse(GephiAPIServer.isNonBrowserRequest(null, "none"));
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java
new file mode 100644
index 000000000..be1ec6676
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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/GephiAI/src/test/java/org/gephi/plugins/mcp/api/VisibleParamTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/VisibleParamTest.java
new file mode 100644
index 000000000..be1164499
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/VisibleParamTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.api;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The {@code visible} switch that lets a caller choose between the full graph and the
+ * filtered view. The defaults matter more than the parsing: read endpoints have always
+ * returned the full graph and the export endpoints have always written the filtered one,
+ * and wiring this parameter must not change either, or every existing caller silently
+ * changes meaning.
+ */
+class VisibleParamTest {
+
+ private static Map params(String key, String value) {
+ Map m = new HashMap<>();
+ if (key != null) m.put(key, value);
+ return m;
+ }
+
+ private static JsonObject body(String json) {
+ return JsonParser.parseString(json).getAsJsonObject();
+ }
+
+ @Test
+ void absentParameterKeepsTheEndpointsHistoricalView() {
+ assertFalse(GephiAPIServer.visibleParam(params(null, null), false), "reads default to the full graph");
+ assertTrue(GephiAPIServer.visibleParam(params(null, null), true), "exports default to the visible graph");
+ assertFalse(GephiAPIServer.visibleParam(null, false), "a null map must not throw");
+ assertFalse(GephiAPIServer.visibleParam(params("visible", " "), false), "blank is treated as absent");
+ }
+
+ @Test
+ void queryParameterIsHonouredInBothDirections() {
+ assertTrue(GephiAPIServer.visibleParam(params("visible", "true"), false));
+ assertTrue(GephiAPIServer.visibleParam(params("visible", "TRUE"), false));
+ assertTrue(GephiAPIServer.visibleParam(params("visible", "1"), false));
+ assertFalse(GephiAPIServer.visibleParam(params("visible", "false"), true));
+ assertFalse(GephiAPIServer.visibleParam(params("visible", "0"), true));
+ }
+
+ @Test
+ void garbageFallsBackToTheDefaultRatherThanGuessing() {
+ assertFalse(GephiAPIServer.visibleParam(params("visible", "yes"), false));
+ assertTrue(GephiAPIServer.visibleParam(params("visible", "banana"), true));
+ }
+
+ @Test
+ void bodyFlagIsHonouredAndDefaultsSafely() {
+ assertTrue(GephiAPIServer.visibleBody(body("{}"), true), "absent keeps the export default");
+ assertFalse(GephiAPIServer.visibleBody(body("{}"), false));
+ assertFalse(GephiAPIServer.visibleBody(body("{\"visible\":false}"), true));
+ assertTrue(GephiAPIServer.visibleBody(body("{\"visible\":true}"), false));
+ assertTrue(GephiAPIServer.visibleBody(body("{\"visible\":null}"), true), "explicit null is absent");
+ assertTrue(GephiAPIServer.visibleBody(null, true), "a null body must not throw");
+ }
+
+ @Test
+ void aNonBooleanBodyValueFallsBackRatherThanThrowing() {
+ assertTrue(GephiAPIServer.visibleBody(body("{\"visible\":{\"a\":1}}"), true));
+ assertFalse(GephiAPIServer.visibleBody(body("{\"visible\":[1,2]}"), false));
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java
new file mode 100644
index 000000000..fb3af75ad
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java
@@ -0,0 +1,370 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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]);
+ }
+
+ // ─── Data Laboratory cores (Group D) ─────────────────────────────
+
+ @Test
+ void columnValueFrequenciesCountsPerValue() {
+ GraphModel gm = newModel();
+ GephiControlService.addNodesToModel(gm, List.of(
+ node("a", "team", "red"), node("b", "team", "red"),
+ node("c", "team", "blue"), node("d", "team", "red")));
+
+ JsonObject r = GephiControlService.columnValueFrequenciesCore(gm, "node", "team");
+ assertTrue(r.get("success").getAsBoolean());
+ assertEquals(2, r.get("distinct_values").getAsInt());
+ assertEquals(4, r.get("total").getAsInt());
+ JsonObject freq = r.getAsJsonObject("frequencies");
+ assertEquals(3, freq.get("red").getAsInt());
+ assertEquals(1, freq.get("blue").getAsInt());
+ }
+
+ @Test
+ void columnValueFrequenciesErrorsOnMissingColumn() {
+ GraphModel gm = newModel();
+ GephiControlService.addNodesToModel(gm, List.of(node("a")));
+ JsonObject r = GephiControlService.columnValueFrequenciesCore(gm, "node", "nope");
+ assertFalse(r.get("success").getAsBoolean());
+ }
+
+ @Test
+ void detectDuplicatesGroupsSharedValues() {
+ GraphModel gm = newModel();
+ GephiControlService.addNodesToModel(gm, List.of(
+ node("a", "email", "x@y.com"), node("b", "email", "x@y.com"),
+ node("c", "email", "z@y.com"), node("d", "email", "x@y.com")));
+
+ JsonObject r = GephiControlService.detectDuplicatesCore(gm, "node", "email", true);
+ assertTrue(r.get("success").getAsBoolean());
+ assertEquals(1, r.get("group_count").getAsInt()); // only x@y.com is duplicated
+ assertEquals(3, r.getAsJsonArray("duplicate_groups").get(0).getAsJsonArray().size());
+ }
+
+ // ─── Typed parallel edges (Group F) ──────────────────────────────
+
+ private static GraphModel modelWithNodes(String... ids) {
+ GraphModel gm = newModel();
+ java.util.List> ns = new java.util.ArrayList<>();
+ for (String id : ids) ns.add(node(id));
+ GephiControlService.addNodesToModel(gm, ns);
+ return gm;
+ }
+
+ @Test
+ void untypedDuplicateEdgeStillBlocked() {
+ // Regression: the pre-existing single-edge-per-pair rule must be unchanged
+ // when no edge_type is given.
+ GraphModel gm = modelWithNodes("a", "b");
+ assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, null).get("success").getAsBoolean());
+ assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, null).get("success").getAsBoolean());
+ assertEquals(1, gm.getGraph().getEdgeCount());
+ }
+
+ @Test
+ void differentTypedEdgesCoexistBetweenSamePair() {
+ GraphModel gm = modelWithNodes("a", "b");
+ assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean());
+ assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "coauthor").get("success").getAsBoolean());
+ assertEquals(2, gm.getGraph().getEdgeCount(), "two typed parallel edges should coexist");
+ assertTrue(gm.getEdgeTypeCount() >= 2);
+ }
+
+ @Test
+ void sameTypedEdgeIsStillBlocked() {
+ GraphModel gm = modelWithNodes("a", "b");
+ assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean());
+ assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean(),
+ "a second edge of the SAME type between the same pair is still a duplicate");
+ assertEquals(1, gm.getGraph().getEdgeCount());
+ }
+
+ @Test
+ void batchAddHonorsPerEdgeType() {
+ GraphModel gm = modelWithNodes("a", "b");
+ Map e1 = new LinkedHashMap<>();
+ e1.put("source", "a"); e1.put("target", "b"); e1.put("edge_type", "cites");
+ Map e2 = new LinkedHashMap<>();
+ e2.put("source", "a"); e2.put("target", "b"); e2.put("edge_type", "coauthor");
+ JsonObject r = GephiControlService.addEdgesToModel(gm, List.of(e1, e2));
+ assertEquals(2, r.get("added").getAsInt());
+ assertEquals(2, gm.getGraph().getEdgeCount());
+ }
+
+ @Test
+ void detectDuplicatesRespectsCaseInsensitivity() {
+ GraphModel gm = newModel();
+ GephiControlService.addNodesToModel(gm, List.of(
+ node("a", "name", "Alice"), node("b", "name", "alice")));
+
+ assertEquals(0, GephiControlService.detectDuplicatesCore(gm, "node", "name", true)
+ .get("group_count").getAsInt()); // case-sensitive: distinct
+ assertEquals(1, GephiControlService.detectDuplicatesCore(gm, "node", "name", false)
+ .get("group_count").getAsInt()); // case-insensitive: same
+ }
+
+ // ── empty-graph edge cases ───────────────────────────────────────────
+
+ @Test
+ void buildCsvOnEmptyGraphIsHeadersOnlyWithNoDataRows() {
+ GraphModel gm = newModel();
+ String csv = GephiControlService.buildCsv(gm, ",", "both");
+ String[] sections = csv.split("\n\n");
+ assertEquals(2, sections.length, "expected a node section and an edge section");
+ String[] nodeLines = sections[0].split("\n");
+ assertEquals(1, nodeLines.length, "empty graph must yield the node header and zero rows");
+ assertTrue(nodeLines[0].startsWith("Id,Label"), "node header missing: " + nodeLines[0]);
+ String[] edgeLines = sections[1].split("\n");
+ assertEquals(1, edgeLines.length, "empty graph must yield the edge header and zero rows");
+ assertTrue(edgeLines[0].startsWith("Source,Target,Weight"), "edge header missing: " + edgeLines[0]);
+ }
+
+ @Test
+ void numericRangeOnEmptyGraphIsNull() {
+ GraphModel gm = newModel();
+ Column score = gm.getNodeTable().addColumn("score", Double.class);
+ // No nodes at all (not merely no numeric values): must be null, not [∞, -∞].
+ assertNull(GephiControlService.numericRange(gm.getGraph(), score));
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java
new file mode 100644
index 000000000..35a155c66
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+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));
+ }
+
+ @Test
+ void layoutMatchFallsBackToFirstSubstringMatch() {
+ // "atlas" matches no name exactly; the FIRST substring match ("Force Atlas",
+ // which precedes "ForceAtlas 2" in the registry order) must win.
+ assertEquals("Force Atlas", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "atlas")));
+ // A single-name substring resolves to that name.
+ assertEquals("OpenOrd", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "openo")));
+ // Space folding applies to substring matching too: "chtermanrein" only matches
+ // "Fruchterman Reingold" once the space is folded out of the candidate name.
+ assertEquals("Fruchterman Reingold",
+ LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "chtermanrein")));
+ }
+
+ // ── screenshot helpers (gephi_export_screenshot's async-completion detection) ─
+
+ @Test
+ void pollForNewFileFindsAFileWrittenAfterPollingStarts() throws Exception {
+ File dir = Files.createTempDirectory("poll-test-").toFile();
+ try {
+ Thread writer = new Thread(() -> {
+ try {
+ Thread.sleep(50);
+ new File(dir, "shot.png").createNewFile();
+ } catch (Exception ignored) { }
+ });
+ writer.start();
+ File found = GephiControlService.pollForNewFile(dir, 2_000);
+ writer.join();
+ assertEquals("shot.png", found.getName());
+ } finally {
+ deleteRecursively(dir);
+ }
+ }
+
+ @Test
+ void pollForNewFileReturnsNullOnTimeoutWhenDirStaysEmpty() throws Exception {
+ File dir = Files.createTempDirectory("poll-test-").toFile();
+ try {
+ assertNull(GephiControlService.pollForNewFile(dir, 100));
+ } finally {
+ deleteRecursively(dir);
+ }
+ }
+
+ @Test
+ void waitForStableFileSizeTrueOnceWritesStop() throws Exception {
+ File f = File.createTempFile("stable-test-", ".png");
+ try {
+ writeBytes(f, new byte[]{1, 2, 3});
+ assertTrue(GephiControlService.waitForStableFileSize(f, 1_000));
+ } finally {
+ f.delete();
+ }
+ }
+
+ @Test
+ void waitForStableFileSizeFalseOnEmptyFile() throws Exception {
+ File f = File.createTempFile("stable-test-empty-", ".png");
+ try {
+ assertFalse(GephiControlService.waitForStableFileSize(f, 150));
+ } finally {
+ f.delete();
+ }
+ }
+
+ @Test
+ void deleteDirQuietlyRemovesFilesAndDirectory() throws Exception {
+ File dir = Files.createTempDirectory("cleanup-test-").toFile();
+ writeBytes(new File(dir, "a.png"), new byte[]{1});
+ writeBytes(new File(dir, "b.png"), new byte[]{2});
+ assertEquals(2, dir.listFiles().length);
+
+ GephiControlService.deleteDirQuietly(dir);
+
+ assertFalse(dir.exists());
+ }
+
+ @Test
+ void deleteDirQuietlyToleratesAlreadyEmptyDirectory() throws Exception {
+ File dir = Files.createTempDirectory("cleanup-test-empty-").toFile();
+ GephiControlService.deleteDirQuietly(dir);
+ assertFalse(dir.exists());
+ }
+
+ private static void writeBytes(File f, byte[] data) throws IOException {
+ try (FileOutputStream out = new FileOutputStream(f)) {
+ out.write(data);
+ }
+ }
+
+ private static void deleteRecursively(File f) {
+ File[] children = f.listFiles();
+ if (children != null) for (File c : children) deleteRecursively(c);
+ f.delete();
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutDefaultsTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutDefaultsTest.java
new file mode 100644
index 000000000..3ded7bee1
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutDefaultsTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.gephi.graph.api.GraphModel;
+import org.gephi.layout.plugin.forceAtlas2.ForceAtlas2Builder;
+import org.gephi.layout.plugin.force.yifanHu.YifanHu;
+import org.gephi.layout.plugin.openord.OpenOrdLayoutBuilder;
+import org.gephi.layout.spi.Layout;
+import org.gephi.layout.spi.LayoutProperty;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Pins the premise behind the {@code findLayout()} reset.
+ *
+ * A layout straight out of {@code buildLayout()} has its properties at Java zero-values;
+ * Gephi's real defaults are installed by {@code resetPropertiesValues()}, which the Gephi UI
+ * calls on selection. The MCP plugin never called it, so layouts ran on zeros: OpenOrd with
+ * {@code Layout Size} 0 collapsed every node onto (0,0), and Yifan Hu with
+ * {@code optimalDistance} 0 was a no-op that still reported success.
+ *
+ *
These tests construct the real Gephi layouts directly (no NetBeans platform) and assert
+ * both halves: that the zeros are really there before the reset, and that the reset clears
+ * them. If a future Gephi version starts self-initializing these layouts, the "before"
+ * assertions fail loudly rather than the fix quietly becoming redundant.
+ */
+class LayoutDefaultsTest {
+
+ private static double numericProperty(Layout layout, String displayName) throws Exception {
+ for (LayoutProperty p : layout.getProperties()) {
+ if (displayName.equals(p.getProperty().getDisplayName())) {
+ Object v = p.getProperty().getValue();
+ return v == null ? 0d : ((Number) v).doubleValue();
+ }
+ }
+ throw new AssertionError("No such layout property: " + displayName);
+ }
+
+ /** OpenOrd: Layout Size 0 is what collapsed every node onto the origin. */
+ @Test
+ void openOrdStartsOnZerosAndResetFixesIt() throws Exception {
+ Layout layout = new OpenOrdLayoutBuilder().buildLayout();
+ assertNotNull(layout);
+ layout.setGraphModel(GraphModel.Factory.newInstance());
+
+ assertEquals(0d, numericProperty(layout, "Layout Size"), 0d,
+ "expected an un-reset OpenOrd to report Layout Size 0");
+ assertEquals(0d, numericProperty(layout, "Num Iterations"), 0d,
+ "expected an un-reset OpenOrd to report Num Iterations 0");
+
+ layout.resetPropertiesValues();
+
+ assertTrue(numericProperty(layout, "Layout Size") > 0d,
+ "reset must give OpenOrd a non-zero coordinate span, or the layout collapses");
+ assertTrue(numericProperty(layout, "Num Iterations") > 0d,
+ "reset must give OpenOrd a non-zero iteration count");
+ }
+
+ /** Yifan Hu: optimalDistance/stepRatio 0 made the algorithm a silent no-op. */
+ @Test
+ void yifanHuStartsOnZerosAndResetFixesIt() throws Exception {
+ Layout layout = new YifanHu().buildLayout();
+ assertNotNull(layout);
+ layout.setGraphModel(GraphModel.Factory.newInstance());
+
+ assertEquals(0d, numericProperty(layout, "Optimal Distance"), 0d,
+ "expected an un-reset Yifan Hu to report Optimal Distance 0");
+ assertEquals(0d, numericProperty(layout, "Step ratio"), 0d,
+ "expected an un-reset Yifan Hu to report Step ratio 0");
+
+ layout.resetPropertiesValues();
+
+ assertTrue(numericProperty(layout, "Optimal Distance") > 0d,
+ "reset must give Yifan Hu a non-zero optimal distance, or it does nothing");
+ assertTrue(numericProperty(layout, "Step ratio") > 0d,
+ "reset must give Yifan Hu a non-zero step ratio");
+ }
+
+ /**
+ * ForceAtlas 2 was never affected — it is the control case, and the reason the bug went
+ * unnoticed: the workhorse layout self-initializes, so only the others ran on zeros.
+ */
+ @Test
+ void forceAtlas2SelfInitializesBeforeAnyReset() throws Exception {
+ Layout layout = new ForceAtlas2Builder().buildLayout();
+ assertNotNull(layout);
+ layout.setGraphModel(GraphModel.Factory.newInstance());
+
+ assertTrue(numericProperty(layout, "Scaling") > 0d,
+ "ForceAtlas 2 is expected to arrive already initialized");
+ assertTrue(numericProperty(layout, "Tolerance (speed)") > 0d,
+ "ForceAtlas 2 is expected to arrive already initialized");
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LockContentionTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LockContentionTest.java
new file mode 100644
index 000000000..c4b731c64
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LockContentionTest.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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 java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import org.gephi.graph.api.Graph;
+import org.gephi.graph.api.GraphModel;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Contention tests for the deadlock-safe lock helpers, against the real
+ * ReentrantReadWriteLock behind a standalone GraphModel (two threads, no running
+ * Gephi). lockWrite and lockRead poll a timed tryLock instead of parking in the
+ * lock's wait queue (see the comments on those helpers); what matters under
+ * contention is that they wait through a held lock and then genuinely acquire,
+ * rather than failing fast, wedging forever, or reporting a hold they do not have.
+ *
+ *
Contention is established by a latch the test itself controls, never by a
+ * sleep. The holder keeps the lock until this test releases it, so "the contender
+ * is still blocked" is a fact about the lock rather than a guess about scheduling.
+ * The only wall-clock value here is the probe below, and it can fail in one
+ * direction only: if the contender acquires while the holder provably still holds,
+ * which is the defect these tests exist to catch.
+ */
+class LockContentionTest {
+
+ /** How long to watch a contender that must not succeed yet. */
+ private static final long BLOCKED_PROBE_MS = 300;
+
+ /** Generous ceiling for an acquisition that should follow release almost at once. */
+ private static final long ACQUIRE_TIMEOUT_S = 15;
+
+ private static Graph newGraph() {
+ return GraphModel.Factory.newInstance().getGraph();
+ }
+
+ @Test
+ void lockWriteWaitsOutAContendingReaderAndActuallyAcquires() throws Exception {
+ Graph g = newGraph();
+ ReentrantReadWriteLock.ReadLock rl = GephiControlService.readLockHandle(g);
+ assertNotNull(rl, "read lock handle must be reachable (lockWrite depends on it)");
+
+ CountDownLatch readerHolds = new CountDownLatch(1);
+ CountDownLatch releaseReader = new CountDownLatch(1);
+ Thread reader = new Thread(() -> {
+ rl.lock();
+ try {
+ readerHolds.countDown();
+ releaseReader.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ } finally {
+ rl.unlock();
+ }
+ }, "contending-reader");
+ reader.setDaemon(true);
+ reader.start();
+ assertTrue(readerHolds.await(5, TimeUnit.SECONDS), "reader thread failed to start");
+
+ // lockWrite runs on its own thread because a write hold count is per-thread,
+ // and because the main thread must stay free to release the reader.
+ CountDownLatch acquired = new CountDownLatch(1);
+ AtomicInteger holdCountWhileHeld = new AtomicInteger(-1);
+ AtomicReference failure = new AtomicReference<>();
+ Thread writer = new Thread(() -> {
+ try {
+ GephiControlService.lockWrite(g);
+ try {
+ holdCountWhileHeld.set(g.getLock().getWriteHoldCount());
+ } finally {
+ GephiControlService.unlockWrite(g);
+ }
+ } catch (Throwable t) {
+ failure.set(t);
+ } finally {
+ acquired.countDown();
+ }
+ }, "contending-writer");
+ writer.setDaemon(true);
+ writer.start();
+
+ // The reader still holds, and only this thread can release it, so a writer
+ // that finishes here acquired a write lock over a live read hold.
+ assertFalse(acquired.await(BLOCKED_PROBE_MS, TimeUnit.MILLISECONDS),
+ "lockWrite acquired while a reader still held the lock");
+
+ releaseReader.countDown();
+
+ assertTrue(acquired.await(ACQUIRE_TIMEOUT_S, TimeUnit.SECONDS),
+ "lockWrite never acquired after the reader released");
+ assertNull(failure.get(), () -> "lockWrite threw: " + failure.get());
+ assertEquals(1, holdCountWhileHeld.get(),
+ "lockWrite returned without actually holding the write lock");
+
+ reader.join(5_000);
+ writer.join(5_000);
+ assertFalse(reader.isAlive(), "reader thread leaked");
+ assertFalse(writer.isAlive(), "writer thread leaked");
+ }
+
+ @Test
+ void lockReadWaitsOutAHeldWriterAndActuallyAcquires() throws Exception {
+ Graph g = newGraph();
+ ReentrantReadWriteLock.WriteLock wl = GephiControlService.writeLockHandle(g);
+ assertNotNull(wl, "write lock handle must be reachable (lockRead's counterpart)");
+
+ CountDownLatch writerHolds = new CountDownLatch(1);
+ CountDownLatch releaseWriter = new CountDownLatch(1);
+ Thread writer = new Thread(() -> {
+ wl.lock();
+ try {
+ writerHolds.countDown();
+ releaseWriter.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ } finally {
+ wl.unlock();
+ }
+ }, "holding-writer");
+ writer.setDaemon(true);
+ writer.start();
+ assertTrue(writerHolds.await(5, TimeUnit.SECONDS), "writer thread failed to start");
+
+ CountDownLatch acquired = new CountDownLatch(1);
+ AtomicReference failure = new AtomicReference<>();
+ Thread readerThread = new Thread(() -> {
+ try {
+ GephiControlService.lockRead(g);
+ g.readUnlock();
+ } catch (Throwable t) {
+ failure.set(t);
+ } finally {
+ acquired.countDown();
+ }
+ }, "contending-reader");
+ readerThread.setDaemon(true);
+ readerThread.start();
+
+ assertFalse(acquired.await(BLOCKED_PROBE_MS, TimeUnit.MILLISECONDS),
+ "lockRead acquired while a writer still held the lock");
+
+ releaseWriter.countDown();
+
+ assertTrue(acquired.await(ACQUIRE_TIMEOUT_S, TimeUnit.SECONDS),
+ "lockRead never acquired after the writer released");
+ assertNull(failure.get(), () -> "lockRead threw: " + failure.get());
+
+ writer.join(5_000);
+ readerThread.join(5_000);
+ assertFalse(writer.isAlive(), "writer thread leaked");
+ assertFalse(readerThread.isAlive(), "reader thread leaked");
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ServiceRestartTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ServiceRestartTest.java
new file mode 100644
index 000000000..fafc164f5
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ServiceRestartTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.service;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The service survives being shut down and used again.
+ *
+ * Stopping the server from Tools, Gephi AI Server calls {@code shutdown()}, which shuts
+ * the layout executor down for good. The service is a singleton, so starting the server again
+ * hands back the same instance. While the executor field was final, every layout after a
+ * stop-and-start failed with {@code RejectedExecutionException} for the rest of the session,
+ * and the only sign of it was the "Layout already running" message that follows.
+ *
+ *
The plugin's own smoke test used to step around this by calling NanoHTTPD's {@code stop()}
+ * directly rather than {@code stopServer()}, which is what pointed at the defect: when a test
+ * avoids a code path to stay green, the path is worth looking at.
+ */
+class ServiceRestartTest {
+
+ private static ExecutorService executorOf(GephiControlService service) throws Exception {
+ Field f = GephiControlService.class.getDeclaredField("layoutExecutor");
+ f.setAccessible(true);
+ return (ExecutorService) f.get(service);
+ }
+
+ /** Calls the private accessor the layout path uses, which is where the repair happens. */
+ private static ExecutorService liveExecutorOf(GephiControlService service) throws Exception {
+ java.lang.reflect.Method m = GephiControlService.class.getDeclaredMethod("layoutExecutor");
+ m.setAccessible(true);
+ return (ExecutorService) m.invoke(service);
+ }
+
+ @Test
+ void theLayoutExecutorIsUsableAgainAfterShutdown() throws Exception {
+ GephiControlService service = GephiControlService.getInstance();
+
+ ExecutorService before = liveExecutorOf(service);
+ assertNotNull(before, "a layout executor must exist before shutdown");
+ assertFalse(before.isShutdown(), "precondition: the executor starts alive");
+
+ service.shutdown();
+ assertTrue(executorOf(service).isShutdown(), "shutdown() must actually stop the executor");
+
+ ExecutorService after = liveExecutorOf(service);
+ assertNotNull(after);
+ assertFalse(after.isShutdown(),
+ "after a stop and start, the layout executor must be usable again");
+ assertNotSame(before, after, "a shut-down executor cannot be revived; expect a new one");
+
+ // Prove it, rather than trusting isShutdown(): the executor must accept work.
+ assertTrue(after.submit(() -> "ran").get(5, TimeUnit.SECONDS).equals("ran"),
+ "the recreated executor must accept and run a task");
+
+ // Leave the singleton in a working state for whatever runs next.
+ assertFalse(liveExecutorOf(service).isShutdown());
+ }
+
+ @Test
+ void repeatedShutdownsKeepRecovering() throws Exception {
+ GephiControlService service = GephiControlService.getInstance();
+ for (int i = 0; i < 3; i++) {
+ service.shutdown();
+ ExecutorService e = liveExecutorOf(service);
+ assertFalse(e.isShutdown(), "cycle " + i + ": executor must recover");
+ assertTrue(e.submit(() -> true).get(5, TimeUnit.SECONDS), "cycle " + i + ": must run work");
+ }
+ }
+}
diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/ui/PortParseTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/ui/PortParseTest.java
new file mode 100644
index 000000000..d788a75fb
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/ui/PortParseTest.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2026 Matt Artz
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.gephi.plugins.mcp.ui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for the port field validation in the server control dialog. */
+class PortParseTest {
+
+ @Test
+ void validPortsAreAccepted() {
+ assertEquals(8080, ServerControlPanel.parsePort("8080"));
+ assertEquals(1024, ServerControlPanel.parsePort("1024"));
+ assertEquals(65535, ServerControlPanel.parsePort("65535"));
+ assertEquals(8080, ServerControlPanel.parsePort(" 8080 "));
+ }
+
+ @Test
+ void invalidPortsAreRejected() {
+ assertEquals(-1, ServerControlPanel.parsePort(null));
+ assertEquals(-1, ServerControlPanel.parsePort(""));
+ assertEquals(-1, ServerControlPanel.parsePort("abc"));
+ assertEquals(-1, ServerControlPanel.parsePort("-1"));
+ assertEquals(-1, ServerControlPanel.parsePort("0"));
+ assertEquals(-1, ServerControlPanel.parsePort("80"));
+ assertEquals(-1, ServerControlPanel.parsePort("1023"));
+ assertEquals(-1, ServerControlPanel.parsePort("65536"));
+ assertEquals(-1, ServerControlPanel.parsePort("8080.5"));
+ }
+}
diff --git a/pom.xml b/pom.xml
index e04bc86b0..5d95a3529 100644
--- a/pom.xml
+++ b/pom.xml
@@ -86,6 +86,7 @@
modules/BlueskyGephi
+ modules/GephiAI